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.

Pre-1.0 (0.x, where the package lives today): Composer's ^0.x caret locks at the minor, so a minor bump does not auto-propagate — within 0.x a minor is treated as breaking. Consuming territories pin ^0.{minor} (e.g. ^0.3) and each minor bump requires a coordinated consumer-side pin update. New rules therefore arrive through a deliberate pin bump, not silently.

At 1.0 (when the stability target is met): territories pin ^1.0 and inherit minor + patch automatically; any rule that would surface new errors in already-clean code waits for a major bump. Until then, do not document ^1.0 pinning as the live contract — no consumer pins ^1.0 today.

Distribution

Published to Packagist via Trusted Publishing (OIDC), mirroring fs-packages' npm setup. The script-development org is the source of truth.

Distribution Hardening

Armory territories — repos that forge equipment for other territories rather than ship a product (this package, fs-packages) — carry a heavier supply-chain blast radius than a consumer territory: a compromised release reaches every consumer's CI. Two Armory territories now run an identical hardening baseline (fs-packages since 2026-04-13, this package since 2026-05-07), mature enough to codify so future Armory territories inherit it by default:

  • Branch protection with enforce_admins=true. The default branch is protected by a ruleset that applies to administrators too — no admin bypass on the publish branch. A self-merge by a maintainer is still gated by the same required checks as an external contributor's PR. This is stricter than a consumer territory's protection, where admin bypass is an accepted convenience.
  • SHA-pinned GitHub Actions + # v<MAJOR> tracking comment. Every uses: in the workflows pins to a full commit SHA, not a floating tag (@v4 is mutable; a tag can be force-moved to malicious code). Each pin carries a trailing # v<MAJOR> comment so the human-readable version is legible and Dependabot can track it.
  • Dependabot as the SHA tag-tracker. With package-ecosystem: github-actions configured, Dependabot reads the # v<MAJOR> comment, watches for new releases on that major line, and opens a PR bumping the SHA when the upstream tag moves — keeping pins current without reintroducing floating-tag mutability. The pin stays a SHA; only Dependabot moves it, through a reviewable PR.
  • CODEOWNERS as the default review route. A CODEOWNERS file routes review requests to the maintaining team rather than to named individuals, so the review gate survives personnel changes and the protection ruleset's "require review from code owners" has a target.

A new Armory territory adopts all four at creation, not retroactively after a near-miss.

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.