ADR-0021: Canonical PHPStan Rules Package
Accepted Cross-Project UniversalDate: 2026-04-29 Compliance: ISO 27001 (A.8.15, A.5.33), AVG, NEN 7510
Context
The war room operates an Enforcement Escalation Ladder — a doctrine that pushes recurring patterns down the ladder until they're caught at the lowest possible level:
| Level | Mechanism | When it catches |
|---|---|---|
| 1 | Architecture test | CI time |
| 2 | Static analysis rule | Analysis time |
| 3 | CI gate | PR time |
| 4 | Territory doctrine (CLAUDE.md) | Agent reads before working |
| 5 | ADR | Cross-territory governance |
| 6 | Passive monitoring | Next spy mission |
Several doctrine claims that should sit at Level 2 currently sit at Level 4 or Level 6:
- ADR-0011 mandates that Actions are the sole owners of
->transaction(calls. The rule is documented (Level 4) and arch-tested per territory for the who-can-call-it surface, but the Actions-with-multiple-writes-must-wrap-them surface is enforced nowhere. A multi-write Action with no transaction is silently non-compliant. - ADR-0001 mandates that audit records are append-only — "no UPDATE, no DELETE, no SoftDeletes." The rule lives in the migration discipline (no
softDeletes()on audit tables) and in code review. A developer can still writeAuthEventLog::find($id)->update(['user_id' => 0])and nothing in the static analysis catches it. - The war room's
Architectural Principles §Explicit over implicitdiscourages magic helpers likeabort()in favor of explicit exception throws. There is no static check today. - Within Actions,
Illuminate\Database\DatabaseManageris a less-testable, less-tenancy-aware injection target thanIlluminate\Database\ConnectionInterface. This preference exists in emmie's codebase but has no formal doctrine surface.
Meanwhile, emmie has organically accumulated five PHPStan artifacts under backend/app/PHPStan/ that enforce exactly these doctrine claims. They are trapped inside one territory. Kendo, ublgenie, entreezuil, and BIO get none of the protection — and would each have to re-author the same rules independently to gain it.
This is a doctrine-ladder regression by absence: rules that should sit at Level 2 across the entire alliance sit at Level 6 everywhere except emmie.
The campaign report 2026-04-29-phpstan-rules-canonical-promotion.md documents the survey that surfaced this gap.
Decision
A canonical PHPStan rules package — script-development/phpstan-warroom-rules — distributes war-room-doctrine static analysis rules as a Composer dev-dependency consumable by every Laravel territory.
Package Structure
script-development/phpstan-warroom-rules/
├── composer.json
├── extension.neon # Service registrations
├── README.md
├── LICENSE
└── src/
├── Rules/
│ ├── EnforceActionTransactionsRule.php
│ ├── ForbidDatabaseManagerInActionsRule.php
│ ├── ForbidAbortHelperRule.php
│ └── LogRule.php
└── Type/
└── ConnectionTransactionReturnTypeExtension.phpRules Inventory
The package ships five rules. Each row documents the rule's identifier, what it detects, what doctrine it codifies, and the error identifier emitted.
| Rule | Detects | Forbids / Requires | Doctrine source | Error identifier |
|---|---|---|---|---|
EnforceActionTransactionsRule | Action execute() methods | If ≥2 write operations (save, create, update, delete, sync, attach, detach, insert, upsert, updateOrCreate, firstOrCreate, forceDelete, restore, toggle, push, saveQuietly, syncWithoutDetaching, syncWithPivotValues) appear without an enclosing ->transaction( call, error. Constructor-based type analysis excludes calls on non-DB properties (e.g. FilesystemManager::delete()). | ADR-0011 §Action Class Architecture — Actions are atomic; multi-write business logic must commit or roll back together | enforceActionTransactions.missingTransaction |
ForbidDatabaseManagerInActionsRule | Action class constructors (namespace App\Actions\*) | Constructor parameter typed as Illuminate\Database\DatabaseManager is an error. Inject Illuminate\Database\ConnectionInterface instead. | This ADR (§Why ConnectionInterface) | forbidDatabaseManager.inAction |
ForbidAbortHelperRule | Function calls anywhere in App\* | abort(), abort_if(), abort_unless() are errors. Throw an explicit Symfony\Component\HttpKernel\Exception\HttpException (e.g. NotFoundHttpException, UnauthorizedHttpException) instead. | War-room Architectural Principles §Explicit over implicit | forbidAbortHelper.abortUsed |
LogRule | Method calls of update() or delete() on any class | If the receiver type's class name contains "Log" or "logs" (case-insensitive substring), error. The Terminology model is excepted by name. | ADR-0001 §Append-only — audit records have no UPDATE, no DELETE | logRule.logModification |
ConnectionTransactionReturnTypeExtension | $connection->transaction(fn() => $foo) calls on ConnectionInterface | (Type extension, not a rule.) Resolves return type to the closure's return type instead of mixed. | Quality-of-life — enables strict typing of transaction call sites. Pre-requisite for downstream rules that reason about transactional code. | (n/a) |
Why ConnectionInterface
Illuminate\Database\DatabaseManager is a multi-connection-aware container. Illuminate\Database\ConnectionInterface is a single connection. Action injection should prefer ConnectionInterface for three reasons:
- Testability —
ConnectionInterfacemocks cleanly to one method (transaction());DatabaseManagermocks pull inconnection()-resolution paths that aren't relevant to the unit under test. - Multi-tenancy clarity — In a tenant-aware territory, the active connection is what matters.
DatabaseManager::transaction()resolves through the default connection at call time, which can mask tenant-context bugs. - Smaller surface —
ConnectionInterfacehas the methods an Action actually needs (transaction(), query methods);DatabaseManagerexposes connection management that has no business in business logic.
This preference is documented here, in this ADR, as the doctrine source for ForbidDatabaseManagerInActionsRule. ADR-0011 is not amended; this rule's claim stands on its own.
Adoption
A territory adopts the package by:
- Adding
script-development/phpstan-warroom-rulestocomposer.jsonrequire-dev. - Adding
vendor/script-development/phpstan-warroom-rules/extension.neonto theincludes:block ofphpstan.neon. - (If migrating from inline rules — emmie only) deleting
app/PHPStan/Rules/*.phpandapp/PHPStan/ConnectionTransactionReturnTypeExtension.php, and removing those services fromphpstan.neon. - Running
composer phpstanto verify either green or surface findings to address.
Per-rule disable is supported via phpstan.neon ignoreErrors block, scoped by error identifier and path. Each disable must carry a comment explaining why and link to a remediation plan — same discipline as the canonical templates/phpstan.neon.
Versioning
The package follows semantic versioning:
- Major — a rule's behavior changes in a way that surfaces new errors in code that previously passed (e.g. expanding the write-method list in
EnforceActionTransactionsRule, tighteningLogRule's class-name match). - Minor — a new rule is added, or a rule gains an option that doesn't change defaults.
- Patch — bug fixes, false-positive suppression, performance improvements.
Territories pin to a major version (^1.0). New rules ship in minor releases — opt-in by upgrading; no surprise CI breaks.
Distribution
Published to Packagist via Trusted Publishing (OIDC), mirroring fs-packages' npm setup. The script-development org is the source of truth.
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Leave rules emmie-local; document the pattern for other territories to copy when ready | Rejected | Doctrine ladder regression: rules that should be at Level 2 across the alliance sit at Level 6 everywhere except emmie. Each territory would re-author the rules from scratch when it discovered the gap, drifting from emmie's implementation. |
Inline copy: ship the rule files in templates/phpstan-rules/, copy to each territory's app/PHPStan/, register in each territory's phpstan.neon | Rejected | Drift risk: bug fixes don't propagate. The same defect would have to be patched N times. New rules would have to be cascade-deployed manually. The fs-packages monorepo precedent shows the alliance prefers single-source-of-truth distribution. |
Sync-script hybrid: rules canonical in templates/phpstan-rules/, copied via tooling | Rejected | Sync tooling becomes the drift surface. Composer already solves this problem; reinventing it is unnecessary cost. |
| Composer package via Packagist | Accepted | Single source of truth. Future rules (ADR-0019 hydration, ADR-0020 DTO split, others) accumulate in one place. fs-packages monorepo precedent confirms the alliance has appetite for this distribution model. |
Consequences
Positive
- Doctrine ladder restored to Level 2 across all consuming territories — the four enforced doctrine claims (ADR-0011 multi-write transactions, ADR-0001 append-only audit, "explicit over implicit",
ConnectionInterfacepreference) move from Level 4/6 to Level 2 simultaneously on adoption. - Single source of truth — bug fixes and rule refinements ship to all territories via
composer update, not via N parallel edits. - Future rules have a home — ADR-0019 (explicit hydration) and ADR-0020 (DTO split) static checks land in the same package. Each new ADR with a static-check surface gets a one-PR landing path instead of a cross-territory cascade.
- Doctrine documentation concentrates — this ADR documents what each rule enforces and why. The package README is operational; the ADR is canonical.
- Onboarding leverage — a new territory joining the alliance picks up all current and future static-check enforcement in one composer require.
Negative
- New package to maintain — composer.json, README, CHANGELOG, release pipeline. Trusted Publishing reduces but does not eliminate this overhead.
- Version coordination — territories on stale versions miss new rules until they upgrade. Mitigated by minor-version policy (new rules don't break existing code) but still requires an active upgrade path.
- PHP-version coupling — the rules use PHP 8.3+ syntax (
private const string). Territories on older PHP (currently: daymate on Laravel 9) cannot consume the package without backport or until they upgrade. - First-adoption findings — territories with latent violations will see CI fail on the first run. Each cascade deployment may surface real findings to fix before the package can land cleanly.
Risks
- False positives blocking CI — A rule's heuristic misfires (e.g.
LogRule's substring match on "Log" snags an unrelatedMyLogicService). Mitigation: path-scopedignoreErrorswith comment explaining the false positive; tighten the rule in a patch release. - Pest arch test overlap — Some territories may have existing arch tests covering subsets of these rules. Mitigation: keep arch tests as additional defense (Level 1 + Level 2 = belt-and-suspenders for high-stakes rules like the transaction owner).
- Rule drift between source-in-emmie and package — Until emmie's deployment lands, the rule files exist twice (in emmie, in the package). Mitigation: package skeleton is forked from emmie's files at a commit hash recorded in the campaign report; emmie's deployment removes the inline copies, eliminating the divergence surface.
- Package becomes a dumping ground — Future contributors add rules without ADR backing. Mitigation: every new rule requires either a referenced ADR or a §Why-section in this ADR (amended). Rules without doctrine source are rejected.
Enforcement
The package is the enforcement. Each rule self-enforces on composer phpstan and at CI time.
| What | Mechanism | Scope |
|---|---|---|
| Multi-write Action without transaction | EnforceActionTransactionsRule | App\Actions\* |
DatabaseManager injection in Actions | ForbidDatabaseManagerInActionsRule | App\Actions\* |
abort() family helpers | ForbidAbortHelperRule | App\* (configurable per territory) |
update() / delete() on Log models | LogRule | All code analyzed by PHPStan |
| ADR currency | /interrogate skill | This ADR |
Resolved Questions
Why a Composer package instead of Pest architecture tests for these rules?
Resolved 2026-04-29. Pest arch tests are excellent for structural claims (X must be final readonly; Y must extend Z; class-naming patterns). They are weaker for behavioral claims that require type analysis or AST traversal. EnforceActionTransactionsRule and ConnectionTransactionReturnTypeExtension need PHPStan's type inference — they cannot be expressed as arch-test rules. Once we're shipping a PHPStan extension, the marginal cost of including the simpler rules (ForbidAbortHelperRule, LogRule) is near-zero. Bundling them gives one cohesive doctrine-static-check surface.
Why not amend ADR-0011 to formalize the ConnectionInterface preference?
Resolved 2026-04-29. ADR-0011 was just amended on 2026-04-24 (HTTP-layer transaction ban). A second amendment within five days dilutes the document's stability. The ConnectionInterface preference is related to ADR-0011 but stands as a distinct claim — it's about constructor injection target, not transaction ownership. This ADR documents it cleanly without over-burdening ADR-0011.
What about territories on PHP < 8.3 (currently daymate on Laravel 9)?
Resolved 2026-04-29. The package's composer.json will declare "php": "^8.3". Daymate is excluded from Phase 1 cascade. Two paths forward when daymate's leg arrives: (1) backport the PHP 8.3 syntax (private const string → private const) in a daymate-compatible release line; or (2) defer until daymate's Laravel-12 + PHP-8.3+ upgrade lands. The decision is deferred until the package skeleton stabilizes and we know the maintenance burden of a backport line.
What if a territory disagrees with one of the rules?
Resolved 2026-04-29. Rules can be disabled per-territory in phpstan.neon's ignoreErrors block, scoped by error identifier and (optionally) path. Each disable must carry a comment with rationale. If a territory disables a rule and also doesn't expect to fix the underlying violation, that's a doctrine deviation that should be flagged for deliberation — not silently held.
What if a territory uses Larastan's noModelMake and we eventually add explicit-hydration rules to this package?
Resolved 2026-04-29. Out of scope for Phase 1 — this is the Phase 2 question. Larastan's noModelMake covers Model::make() only. The full ADR-0019 surface (create, fill, forceFill, update, $fillable, $guarded) requires custom rules. The package will gain an EnforceExplicitHydrationRule in Phase 2 of the parent campaign. Larastan's noModelMake and the package rule will coexist — they cover different specific calls.
Implementation
| Territory | State | Notes |
|---|---|---|
| emmie | Complete | Origin territory. Pure dogfood round-trip — rules forked into the package, emmie now consumes them and the inline copies are deleted. PR emmie-app/emmie#189, commit 383c06a91dc, merged 2026-04-29. Suppression: Terminology model exempted from LogRule's substring breadth via consumer phpstan.neon ignoreErrors (the documented per-territory contract — emmie's hardcoded exception was dropped during package promotion). |
| kendo | Complete | Phase 1 cascade — second adopter, first non-donor territory. Discovery pass run pre-deployment surfaced 1 violation (ProvisionTenantDatabaseAction legitimately injects DatabaseManager::purge('tenant') for tenant connection management); suppressed via consumer phpstan.neon ignoreErrors with documented Reason citing structural analogy to AuditLogWriter per ADR-0011 amendment. Two kendo-specific inline rules retained (NullableScalarExtractionRule, FormRequestScalarTypeSyncRule — not shipped by package). PR script-development/kendo#1002, commit 3a36508f8, merged 2026-04-29. reportUnmatchedIgnoredErrors: true provides drift hygiene — suppression decay surfaces immediately. |
| ublgenie | Phase 1 PR open | First phased cascade. Discovery pass surfaced 25 findings: 23 systemic forbidDatabaseManager.inAction (every Action), 1 LogRule substring false positive ($invoice->logs() vs InvoiceAuditLog), 1 real forbidAbortHelper.abortUsed in BulkDeleteInvoicesRequest. Ships package adoption with three documented ignoreErrors entries. PR Back-to-code/ublgenie-app#163, commit bf38faa55ad. Phase 2A (23-Action DatabaseManager → ConnectionInterface migration) + Phase 2B (abort() replacement) pending separate Engineer/Medic deployments. |
| entreezuil | Phase 1 PR open | Cleanest non-donor cascade. Discovery pass surfaced 16 findings, all of one kind: forbidDatabaseManager.inAction across every Action. Zero false positives, zero abort() calls, zero EnforceActionTransactionsRule findings (territory's tests/Arch/HttpTest.php already enforces the HTTP transaction ban). Ships package adoption with one global ignoreErrors entry covering the 16. PR script-development/entreezuil#137, commit 56a1ad95. Phase 2 (16-Action migration) pending. Note: entreezuil's first install attempt against package v0.1.0 failed on Laravel 13 incompatibility — patched to v0.1.1 (constraint `^11.0 |
| lego-storage (brick-inventory vassal) | Not Started | Phase 1 cascade — Building Permit required at BIO's leg per sovereignty pattern. Site Manager coordinates with CFO before adoption. |
| the-laboratory experiments | Not Started | Six Laravel 12 apps (Gatekeeper / War Table / Crucible / Parlour / Smokestacks / Vault) — each adopts independently. Sovereign persona system means lab governs its own cascade pace. |
| daymate-api | Blocked | PHP 8.2 (Laravel 9) — incompatible with package's PHP 8.3+ requirement. Revisit when daymate upgrades to PHP 8.3+ / Laravel 12, or if a backport release line is opened. |
Per-territory suppression precedent emerging: Two cascades have shipped with consumer-side ignoreErrors entries (emmie: Terminology, kendo: ProvisionTenantDatabaseAction). If three or more territories carry the same class of suppression (e.g., infrastructure-layer Actions needing DatabaseManager), ADR-0011 gets a third amendment to formally document the exception class. Until then, per-territory suppression with documented Reason comments is the right level.