Skip to content

ADR-0021: Canonical PHPStan Rules Package

Accepted Cross-Project Universal

Date: 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:

LevelMechanismWhen it catches
1Architecture testCI time
2Static analysis ruleAnalysis time
3CI gatePR time
4Territory doctrine (CLAUDE.md)Agent reads before working
5ADRCross-territory governance
6Passive monitoringNext 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 write AuthEventLog::find($id)->update(['user_id' => 0]) and nothing in the static analysis catches it.
  • The war room's Architectural Principles §Explicit over implicit discourages magic helpers like abort() in favor of explicit exception throws. There is no static check today.
  • Within Actions, Illuminate\Database\DatabaseManager is a less-testable, less-tenancy-aware injection target than Illuminate\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.php

Rules 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.

RuleDetectsForbids / RequiresDoctrine sourceError identifier
EnforceActionTransactionsRuleAction execute() methodsIf ≥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 togetherenforceActionTransactions.missingTransaction
ForbidDatabaseManagerInActionsRuleAction 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
ForbidAbortHelperRuleFunction 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 implicitforbidAbortHelper.abortUsed
LogRuleMethod calls of update() or delete() on any classIf 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 DELETElogRule.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:

  1. TestabilityConnectionInterface mocks cleanly to one method (transaction()); DatabaseManager mocks pull in connection()-resolution paths that aren't relevant to the unit under test.
  2. 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.
  3. Smaller surfaceConnectionInterface has the methods an Action actually needs (transaction(), query methods); DatabaseManager exposes 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:

  1. Adding script-development/phpstan-warroom-rules to composer.json require-dev.
  2. Adding vendor/script-development/phpstan-warroom-rules/extension.neon to the includes: block of phpstan.neon.
  3. (If migrating from inline rules — emmie only) deleting app/PHPStan/Rules/*.php and app/PHPStan/ConnectionTransactionReturnTypeExtension.php, and removing those services from phpstan.neon.
  4. Running composer phpstan to 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, tightening LogRule'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

OptionVerdictReason
Leave rules emmie-local; document the pattern for other territories to copy when readyRejectedDoctrine 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.neonRejectedDrift 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 toolingRejectedSync tooling becomes the drift surface. Composer already solves this problem; reinventing it is unnecessary cost.
Composer package via PackagistAcceptedSingle 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", ConnectionInterface preference) 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 unrelated MyLogicService). Mitigation: path-scoped ignoreErrors with 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.

WhatMechanismScope
Multi-write Action without transactionEnforceActionTransactionsRuleApp\Actions\*
DatabaseManager injection in ActionsForbidDatabaseManagerInActionsRuleApp\Actions\*
abort() family helpersForbidAbortHelperRuleApp\* (configurable per territory)
update() / delete() on Log modelsLogRuleAll code analyzed by PHPStan
ADR currency/interrogate skillThis 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 stringprivate 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

TerritoryStateNotes
emmieCompleteOrigin 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).
kendoCompletePhase 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.
ublgeniePhase 1 PR openFirst 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 DatabaseManagerConnectionInterface migration) + Phase 2B (abort() replacement) pending separate Engineer/Medic deployments.
entreezuilPhase 1 PR openCleanest 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 StartedPhase 1 cascade — Building Permit required at BIO's leg per sovereignty pattern. Site Manager coordinates with CFO before adoption.
the-laboratory experimentsNot StartedSix Laravel 12 apps (Gatekeeper / War Table / Crucible / Parlour / Smokestacks / Vault) — each adopts independently. Sovereign persona system means lab governs its own cascade pace.
daymate-apiBlockedPHP 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.

Architecture documentation for contributors and collaborators.