name: symfony-hardening-rule description: > Decide whether a recurring hardening invariant is worth a CI gate, and add it without hitting the traps. Covers PHPStan custom rules (implementation-shape checks) and the check-hardening-tests.php convention (test-presence checks). Use when the user says "write a PHPStan rule", "add a hardening rule", "gate this pattern", "catch this in CI", or wants to turn a finding into a durable check.
Symfony Hardening Rule
Turns a recurring hardening invariant into a CI gate. The mechanics are obvious from the
three shipped rules (HardenedComparisonRule, UnserializeToStringTrampolineRule,
UnserializeMissingAllowedClassesRule): copy the class shape and the registration. This
skill is the part the example files cannot teach: when a rule is worth building, and the
traps that cost an afternoon.
When to build (and when not)
A PHPStan rule earns its place only if it catches a broad, recurring shape across many
sites. The shipped rules each span dozens (every webhook compare; 60+ __unserialize;
every unserialize()). If your rule effectively targets one class or one call site, it is
too narrow: it carries CI weight for almost no coverage. Use a regression test instead.
(A verify-MAC-before-deserialize rule was built and dropped for exactly this reason: it
matched a single serializer.)
Pick the mechanism by what the invariant is about:
| Invariant is about... | Use |
|---|---|
| A code shape recurring across many sites | a PHPStan rule (this skill) |
| A test existing for a boundary class | extend check-hardening-tests.php |
| A value being attacker-controlled (data-flow) | Psalm taint, or a reviewer note; not a syntactic rule |
| A curated set being complete (subnets, attributes) | a unit test next to the data |
| One class / one site | a regression test, not a rule |
Do not force a taint problem into a syntactic rule: if safe and unsafe look identical at the sink and differ only by where a value came from, the rule is all false positives.
The shape (copy, do not reinvent)
Model the new rule on the existing .github/sa-tools/rules/*Rule.php: global namespace,
Symfony license header, implements Rule with getNodeType()/processNode(), a stable
identifier('symfony.<camelCase>'), and the narrowest node type that sees the shape.
The traps
- Registration is one line plus the file. Drop
<Name>Rule.phpinto.github/sa-tools/rules/and add its class to therules:list in.github/sa-tools/rules.neon(whichphpstan.dist.neonincludes). The folder's autoloader (rules/bootstrap.php, wired once via--autoload-fileinstatic-analysis.yml) and the.gitignorefolder whitelist (!rules,!rules/*) pick the file up with no further edits. A rule that needs constructor arguments takes aservices:entry taggedphpstan.rules.rulein that samerules.neoninstead of therules:shorthand. (PHPStan only autoloads a project-local rule class early enough via--autoload-fileor composer PSR-4 — a neonscanDirectories/bootstrapFilesruns too late — hence the stable bootstrap, which you never touch per rule.) phpstan analysecounts are nondeterministic (result cache + parallel workers report 0 then N for the same input). Verify the logic withPHPStan\Testing\RuleTestCasein a throwawaycomposer require phpstan/phpstan phpunit/phpunitproject, with fixtures for the unsafe shape (must fire), the fixed shape (silent), and the nearest safe look-alike from the decision boundary (silent). Never gate on a localanalysecount.- The diff-lint only flags new errors.
phpstan-diff.phpcompares the PR against the base branch and fails only on errors new to the PR, so standing violations are absorbed. Design target: zero findings on the current tree at introduction (no baseline entry).
Convention path
If the invariant is "this boundary class must ship its regression test", extend
check-hardening-tests.php (pure tokenizer, no autoload) instead of writing a rule. Add the
boundary predicate and the required-test predicate in its existing style; accepted gaps go in
the ALLOWLIST const as a TODO with a reason, never a permanent waiver.
Error handling
- If the invariant needs data-flow, do not ship a syntactic rule that fakes it. Say it is a taint or checklist item and wait for confirmation on the fallback.
- Do not edit
phpstan-diff.phpor the workflow to make a rule pass. Fix the rule. - House conventions: Symfony license header, no em-dashes, no Claude/Anthropic credit, comments sparingly, no issue numbers in code.