Skip to content

Code Standards

The Static Checks job scans the package you changed. Each failure line names its rule:

::error::[switch-stmt] switch() found (use match)

Find the rule below. Rules are blocking (CI fails), advisory (CI warns) or reviewed (a maintainer checks by hand).

The whole package, except vendor/, .git/, .github/, coverage/, node_modules/, tests/, Tests/, testing/, benchmarks/, composer.lock, CHANGELOG.md, KNOWN_ISSUES.md, UPGRADING.md and SECURITY.md. A pattern that fails in src/ is fine in a test.


Blocking. No decorative comment bars in any syntax: a run of ten or more -, =, #, *, _ or ~ after //, #, --, /*, {# or <!--, or a box-drawing run such as ──, ══ or ━━. This covers every file type, including CSS, SQL, XML and Twig.

Fix: delete the bar. If a file needs sections to be readable, split it or reorder its methods.

Blocking. No two consecutive // comment lines in PHP. Files under a config/ directory are exempt, because a published config template is documentation.

Fix: keep one line, or move the reasoning into the docblock.

Blocking. No // line in PHP that starts like code: $var, return , if (, foreach , Something::, use , public , private or protected .

Fix: delete it.

Blocking. No // comment in PHP containing changed in, was previously, used to be, as of v, since v1, deprecated in, renamed from, formerly, prior to v, TODO, FIXME, HACK or XXX.

Fix: history belongs in the CHANGELOG; open an issue for a TODO.

A comment that only restates the next line is not caught by any scanner, but a reviewer will ask you to remove it.

Blocking. No @codeCoverageIgnore.

Fix: write the test, stubbing anything external. Inject the dependency so a test can replace it:

public function __construct(private readonly RawHttpClient $http) {}
// in the test
$http = $this->createMock(RawHttpClient::class);
$http->method('post')->willReturn(['ok' => true]);

Blocking. No switch in PHP. match is strict, returns a value and cannot fall through.

$url = match ($provider) {
'anthropic' => self::ANTHROPIC_URL,
default => self::OLLAMA_URL,
};
$tier = match (true) {
$tokens > 100_000 => 'large',
$tokens > 10_000 => 'medium',
default => 'small',
};

Blocking. Every PHP file contains declare(strict_types=1). Files under tmpl/, templates/, template/, views/ and view/ are exempt, as are the excluded folders above. Without it, PHP silently converts argument types instead of failing.

<?php
declare(strict_types=1);
namespace PhpClaw\Tools;

Advisory. Classes should be final. abstract classes are not reported, and in adapters neither is a Magento class marked // non-final: Magento interceptor required.

A class that extends a framework or CMS base class may need to stay open; the warning still appears, and that is fine. Do not add a // non-final: comment to silence it; that exemption is for Magento interceptors only.

Putting extension points behind an interface, rather than inheritance, keeps the contract explicit.


Blocking. Never send an exception message to the user. Exception text can carry file paths, SQL and credentials.

  • In adapters, CI flags echo of ->getMessage() and an array value set to $e->getMessage().
  • In core, cloud and mcp, CI flags the echo form only. Those packages pass tool and guard messages back to the model and to MCP error frames, which are not end users.

Passing the exception to a logger method (error, warning, info and so on) is allowed.

// Refused
echo $e->getMessage();
return ['error' => $e->getMessage()];
// Allowed
$this->logger->error('Provider call failed', ['exception' => $e]);
return ['error' => 'The request could not be completed.'];

Blocking. No eval(). A quoted 'eval(', for example in a list of forbidden calls, is not a call and is not flagged.

Fix: to choose behaviour by name, use a map:

$handlers = ['sum' => fn (array $n): int => array_sum($n)];
$result = ($handlers[$op] ?? throw new ToolException('Unknown operation'))($args);

Blocking. No Guzzle in the package’s composer.json. Use core’s RawHttpClient, so installing a phpClaw package never adds an HTTP stack, or a version conflict, to someone else’s application.

use PhpClaw\Http\RawHttpClient;
$data = $this->http->post($url, ['Content-Type' => 'application/json'], $payload);

Blocking, CMS adapters. Never hardcode the prefix on a phpClaw table. In .php files CI rejects wp_, jos_, oc_ and ps_ followed by phpclaw_conversations, phpclaw_messages or phpclaw_memory, in the WordPress, Joomla, OpenCart and PrestaShop adapters respectively. Drupal and Magento have no fixed prefix to scan for, but the rule is the same.

PlatformCorrect form
WordPress$wpdb->prefix.'phpclaw_messages'
Joomla'#__phpclaw_messages'
Drupal$this->database->select('phpclaw_messages', 'm')
Magento$this->resource->getTableName('phpclaw_messages')
OpenCartDB_PREFIX.'phpclaw_messages'
PrestaShop_DB_PREFIX_.'phpclaw_messages'

Blocking. At most two description lines before the first @ tag, in every PHP docblock outside tests. Docblocks with three or more Label: lines, or a ## heading, are skipped as command help.

Fix: trim to a one-line summary and keep every tag.

/**
* Resolve the provider for this request.
*
* @return ProviderInterface
*/

Reviewed: the summary must also be true of the body as it is now. A summary on an authorisation check, a scrub, a guard or an ownership rule that claims more than the code enforces is treated as a security bug.

// Wrong: the body filters by owner unless the caller holds manage-all
/**
* Delete all conversations in the given namespace.
*/
// Right
/**
* Delete the acting user's conversations in the namespace, or every user's with manage-all.
*/

Blocking. This is a separate CI job, not part of Static Checks. Outside tests/, Tests/ and benchmarks/:

  • every named function needs a docblock;
  • every documented function needs a @param for each parameter, no @param for a parameter that does not exist, and a @return, including @return void.

Constructors and destructors are skipped by the tag check. @throws is not checked, but document it. For a generic type such as array, document its shape, for example array<int, array<string, mixed>>.

A failure names each function:

::error::2 function(s) missing a docblock:
src/Admin/SettingsPage.php:212 resolveApiKey()

and a docblock missing tags fails with ::error::N docblock(s) with incomplete tags:.

/**
* Forget a memory entry.
*
* @param string $key Memory key to remove.
* @param string $namespace Scoping namespace.
* @return bool True when a row was deleted.
*/
public function forget(string $key, string $namespace): bool

Constants and properties carry no docblock, unless a bare type tag such as /** @var resource */ is the only way to express the type.


Advisory, CMS adapters. No <style> or style= in .twig, .tpl, .phtml or .blade.php files. A display:none toggle and a CSS custom property such as --phpclaw-accent are not flagged.

Fix: move the rule into the adapter’s stylesheet and load it the platform’s way, such as wp_enqueue_style() in WordPress or a *.libraries.yml library in Drupal:

PlatformLoad it with
WordPresswp_enqueue_style()
Joomlathe Web Asset Manager, $wa->registerAndUseStyle()
Drupala *.libraries.yml library attached with #attached['library']
Magentolayout XML <css src="..."/>
OpenCartthe document’s addStyle()
PrestaShopthe controller’s addCSS()

Inline styles cannot be overridden by a theme or allowed by a strict Content Security Policy.


Every package requires PHP ^8.1, so code must run on 8.1: no readonly classes or standalone null, false or true types (8.2), no typed class constants (8.3), no property hooks or asymmetric visibility (8.4). 8.1 features are fine: enums, readonly properties, intersection types, the never return type, first-class callable syntax and new in initializers.

Not every workflow tests on 8.1. Drupal, Joomla, Magento, OpenCart, PrestaShop and WordPress run 8.1 to 8.4; core, cloud and MCP run 8.2 to 8.4, and core also runs a lowest-dependencies job on 8.1; Laravel and Symfony run 8.4 only. A green build is not proof of 8.1 compatibility.

  • Explicit return types everywhere.
  • No static state, except core’s registries and catalogues (ToolRegistry, GuardRegistry, HookRegistry, MemoryRegistry, ProviderRegistry, SkillRegistry and their *Catalogue classes) and the caches HookRunContext, RateLimitGuard, ComposerExtras, DiscoveryCache and Ulid.
  • packages/core has no framework dependencies.
  • Depend on interfaces such as ToolInterface, MemoryInterface and ProviderInterface.

Every package’s pint.json uses Laravel Pint’s laravel preset with no_superfluous_phpdoc_tags turned off, so Pint keeps @return void and undescribed @param tags. Run composer lint and commit the result. CI runs pint --test in the Static Analysis job and fails on unformatted code.


RuleAdapterscore, cloud, mcp
ascii-divider, comment-paragraph, commented-code, history-noteblockingblocking
codecov-ignore, switch-stmt, strict-types, eval-call, no-guzzleblockingblocking
docblock-summaryblockingblocking
Docblock on every functionblocking, own jobblocking, own job
exception-leakblocking, both formsblocking, echo only
final-classadvisoryadvisory
table-prefixblocking, CMS adaptersnot run
inline-cssadvisory, CMS adaptersnot run

The CMS adapters are Drupal, Joomla, Magento, OpenCart, PrestaShop and WordPress. For the other CI jobs, see Contributing.