All notable changes to script-development/phpstan-warroom-rules are documented in this file.
The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
0.5.0 — 2026-06-25
Release-as-a-whole: candidate MAJOR (pre-1.0 minor bump — ^0.4 → ^0.5) — ships two new rules (ForbidHttpExceptionInActionsRule, ForbidResourceWrappedInJsonResponseRule) from the Commander's review of emmie PR #481 (war-room enforcement queue #123 + #124). Both surface new errors in already-clean code wherever a consumer violates, so each consumer adopts on its own ^0.4 → ^0.5 bump PR (the ^0.{minor} caret means ^0.4 excludes 0.5.0 — tagging auto-adopts nobody). Blast radius (surveyed 2026-06-25 origin/development): ZERO violators on emmie + kendo for both rules (the #481 offender is on its branch, not merged) — the per-territory bump is expected clean save for un-merged branch work.
ForbidHttpExceptionInActionsRule— flagsthrowstatements insideApp\Actions\*classes whose thrown expression's type is a subtype ofSymfony\Component\HttpKernel\Exception\HttpExceptionInterface(theHttpExceptionfamily —HttpExceptionitself plus every subclass:NotFoundHttpException,AccessDeniedHttpException,UnprocessableEntityHttpException, …). HTTP status concerns belong to the HTTP layer; an Action that throws a 422 has reached past its boundary into transport. A uniqueness rule belongs in the FormRequest; a domain failure throws a custom domain exception the renderer maps to a status. Identifier:forbidHttpExceptionInActions.httpExceptionInAction. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + Form Request → DTO → Action pipeline (#3). Type-aware sibling ofForbidAbortHelperRule(which bans theabort()helper family whose own message recommendsthrow new HttpException— correct for controllers, wrong for Actions): this rule closes the matching gap on the directthrow new HttpException(...)form inside Actions, catching subclass throws, fully-qualified throws with nouseimport, and typed-value throws that an import-checking arch test would miss.Illuminate\Validation\ValidationExceptionis explicitly OUT of scope — Actions legitimately thrownew ValidationException($validator)for stateful / cross-field validation that cannot live in a static FormRequest; it is not a member of the SymfonyHttpExceptionfamily, so the type gate never fires on it. Action-namespace gate mirrorsForbidDatabaseManagerInActionsRule(App\Actionsprefix via$scope->getNamespace()+str_starts_with). Out of scope: non-App\Actions\*namespaces (controllers, FormRequests, exception renderers, middleware all legitimately raise HTTP-layer exceptions); theabort()helper family (covered byForbidAbortHelperRule). Seed: Commander review of emmie PR #481 —CreateLocationEmailActionthrewHttpException(422, …)for an "override already exists" uniqueness check (war-room enforcement queue #123). Blast radius (surveyed 2026-06-25origin/development): ZERO raw-HttpException-in-Action on emmie + kendo (the #481 offender is on its branch, not merged) — the rule lands green and red-flags #481 at CI once enabled. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has an Action throwing an HTTP-layer exception). Pre-cascade audit required per consumer at Phase-B bump (^0.4 → ^0.5); the emmie + kendo survey shows zero current violators, so the cascade is expected clean fleet-wide save for any un-merged branch work.ForbidResourceWrappedInJsonResponseRule— flagsresponse()->json($payload, …)andnew JsonResponse($payload, …)insideApp\Http\Controllers\*classes only when$payload's resolved type is a subtype ofIlluminate\Http\Resources\Json\JsonResource. AJsonResourceis already aResponsable— Laravel serializes it to a JSON response on its own; wrapping one in an explicit JSON response double-wraps the payload and discards the resource's own response shaping. Return the resource directly instead:return XxxResource::fromModel($model);(HTTP 200). Identifier:forbidResourceWrappedInJsonResponse.resourceWrapped. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + ADR-0009 (Unified ResourceData Pattern — resources own their own response serialization). Type-awareness is mandatory: a blanket string-ban onresponse()->json(...)would false-positive on the overwhelmingly common legitimate sites that wrap a plain array / DTO / scalar / message envelope, and onresponse()->json(null, 204)(a 2026-06-25 fleet survey sized ~24 emmie + ~43 kendoresponse()->json/JsonResponsesites, almost all legitimate non-Resource payloads — a blanket ban would be ~67 false positives). Two AST shapes inspected: (1)MethodCallnamedjsonwhose receiver is theresponse()helperFuncCall(AST-shape match — the helper'sResponseFactoryreturn type is unloaded in stub-only analysis environments, mirroring howEnforceCurrentUserAttributeRulematches theauth()helper); (2)New_ofIlluminate\Http\JsonResponse(FQCN via$scope->resolveName()). Named-envelope edge (decided: EXCLUDE): a resource (or resource collection) nested under a named array key — e.g. emmieRegistrationBroadcastController:28's['registrations' => …Resource::collect(...)]— is a deliberate response envelope, not a bare double-wrap; the first argument is anArray_whose type isarray<…>, not aJsonResourcesubtype, so the type gate naturally lets it through. Theresponse()->json($resource, 201)status-override form still fires (the wrap is the violation, not the status; resource-with-non-200 is an unrelated future investigation). Controller-namespace gate mirrorsForbidEloquentMutationInControllersRule. Out of scope: non-App\Http\Controllers\*namespaces; non-JsonResource payloads; resources nested in any envelope. Seed: Commander review of emmie PR #481 —EmailController::storereturnedresponse()->json(EmailResource::fromModel($email), 201)while siblingupdate()correctly returned the Resource directly (war-room enforcement queue #124). Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a controller wraps a resource in an explicit JSON response). Pre-cascade audit required per consumer at Phase-B bump (^0.4 → ^0.5); the 2026-06-25 survey found ~0 current violators on emmie + kendodevelopment(the #481 offender is on its branch).
0.4.0 — 2026-06-15
Release-as-a-whole: MAJOR — ships four new rules (EnforceCurrentUserAttributeRule, ForbidEloquentMutationInControllersRule, EnforceAuditTransactionScopeRule, EnforceFormRequestToDtoRule) plus two folded no-op fixes (the NEON double-backslash defect that silenced EnforceFormRequestToDto + EnforceResourceDataValidatorOptIn for default-configured consumers — so EnforceResourceDataValidatorOptInRule, shipped in v0.3.0, has been a no-op on ^0.3, and its real surface also appears on bump). Unlike v0.3.0 (audited clean fleet-wide before tag), v0.4.0 is tagged known-dirty. The per-rule "pre-cascade audit" notes below move to per-territory Phase-B bump time, not before-tag: the ^0.{minor} caret means ^0.3 excludes 0.4.0, so tagging auto-adopts nobody — each consumer remediates and goes green on its own ^0.3 → ^0.4 bump PR (each carrying baseline-absorb for the other rules plus any login-handler ignoreErrors; see README §EnforceCurrentUserAttributeRule — false positives). Current-user remediation is already in review on its four territories: ublgenie #341, entreezuil #226, emmie #413, codebook #380. Phase B follows as per-territory war-room dispatches at our own pace.
- Tests: direct type-inference coverage for
ConnectionTransactionReturnTypeExtensionvia aPHPStan\Testing\TypeInferenceTestCase(tests/Type/ConnectionTransactionReturnTypeExtensionTest.php+ fixturetests/Fixtures/ConnectionTransactionReturnType/transaction-return-type.php). The extension previously had no direct test — it was only exercised implicitly by audit-snapshot rule fixtures, none of which asserted the resolved return type. The new fixture loadsextension.neon(same config consumers register) andassertType()s the inferred type of$connection->transaction(...)for closures returning a constant scalar, an object/DTO, a nullable, an array shape, and a widened (non-constant) scalar — pinning that the extension forwards the closure acceptor's return type rather thanmixed. Test-only; no consumer-facing surface. Closes Quartermaster F-2. Versioning: none (internal test coverage). EnforceCurrentUserAttributeRule— flags calls toRequest::user()/Auth::user()/auth()->user()inside classes in theApp\Http\Controllersnamespace. The canonical fix is Laravel's#[\Illuminate\Container\Attributes\CurrentUser]container attribute on the method parameter — eliminates the implicit-nullable-then-assert dance ($user = $request->user(); assert($user instanceof User);) introduced by emmie PR #263 (EMMIE-0197) and recurring across the war-room fleet. Doctrine: war-room §Architectural Principles — Explicit over implicit. Identifier:enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser. Detection branches on three call shapes viaCallLikeregistration (mirrorsLogRulev0.3.0 shape):MethodCallonIlluminate\Http\Requestsubtype receiver (type-based viaObjectType::isSuperTypeOf());MethodCallwhose receiver is aFuncCall('auth')(AST-shape match — the helper's return type is unloaded in stub-only environments);StaticCallresolving toIlluminate\Support\Facades\Auth(FQCN comparison via$scope->resolveName()). Scoped to controllers via theApp\Http\Controllersnamespace prefix ($scope->getNamespace()+str_starts_with) — mirrorsForbidEloquentMutationInControllersRuleand the canonical "controllers are identified by theApp\Http\Controllersnamespace" convention. FormRequest (App\Http\Requests, where$this->user()is canonical because container-attribute injection does not apply toFormRequest::rules()/toDto()/authorize()invocations), middleware (App\Http\Middleware), services, Actions (App\Actions), jobs, and console commands are silent because their namespaces do not start with the controller prefix. Origin: war-room cross-territory recon 2026-05-22 (50+ violations across codebook, ublgenie, entreezuil, emmie; kendo already clean with 30 adopted sites). Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has un-migrated controllers). The release PR will determine whether this collapses into the existing v0.3.0 Unreleased block (already a Major) or cuts as a separate Major (v0.4.0) after v0.3.0 ships. Pre-cascade audit required at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the 0.4.0 release header) — consuming territories will need either Medic dispatches to migrate (ublgenie 6 sites, entreezuil 3 sites, emmie 2 sites) or PHPStan-baseline staging (codebook ~40+ sites — safer than mass-edit on lightly-staffed AVG/NEN-7510-downstream territory). Kendo gets a constraint bump only (zero violations). Multi-guard ergonomics (#[CurrentUser] Client $clientresolves via client guard,#[CurrentUser] User $uservia user guard — verified in emmie'sClientController::me) work as expected: Laravel dispatches by typed parameter. Out of scope v1:Auth::guard('name')->user()and other guard-specific resolution paths — rare, substitution is more nuanced (#[CurrentUser(guard: 'name')]), do not appear in the recon yield.ForbidEloquentMutationInControllersRule— bans Eloquent persistence APIs (save,update,delete,create,destroy,forceDelete,forceFill,push,restore,touch, and their*OrFail/*Quietly/*OrCreatevariants — 24-method blocklist) onIlluminate\Database\Eloquent\Modelsubclasses andIlluminate\Database\Eloquent\Builderchains when the call site is inside anApp\Http\Controllers\*class (including sub-namespaces like kendo'sApp\Http\Controllers\Central\*, matched viastr_starts_with). Reads (find,where,get,first,paginate,pluck,count,exists,query) are deliberately permitted — route-model binding, ResourceData hydration, and policy checks need controller-level Model access; the doctrine line is "Controllers may READ Models, but MUST NOT mutate them." Identifier:forbidEloquentMutationInControllers.eloquentMutationInController. Doctrine: ADR-0011 (Action Class Architecture) — Actions are the chokepoint for mutations — combined with ADR-0019 (Explicit Model Hydration) —Model::create()/fill()/forceFill()/update()banned application-wide; this rule enforces the controller surface where the violations have been historically common. Algorithm: namespace gate (App\Http\Controllers) → recursively walk everyClassMethodbody collectingMethodCall+StaticCallnodes → forMethodCall, fire ifObjectType::isSuperTypeOf()againstModelORBuildermatches the receiver type and the method name is in the blocklist; forStaticCall, fire ifScope::resolveName()resolves to a Model subclass FQCN and the method name is in the blocklist. Builder coverage is type-aware (User::query()->where(...)->update([...])fires) — the generic parameter is not unwrapped becauseObjectTypematchesBuilder<User>as a subtype of the unparameterizedBuildercleanly, no brittle generic introspection needed. Supersedes the consumer-side string-match Pest arch tests in kendo (backend/tests/Arch/ControllersTest.phpcontrollers must not call Eloquent write methods directly), ublgenie + entreezuil (tests/Arch/ControllersTest.phpof the same shape), and the bridge subset in ISMS (backend/tests/Architecture/ControllerCurrentUserTest.phpfrom PR #10, 2026-05-28). The string-match shape catches->save(,->update([,->delete(,->forceDelete(but cannot discriminateModel::create()fromResponse::create(),Collection::push()fromModel::push(), or->update($vars)without an inline array literal — the type-aware AST inspection here closes those gaps. Cross-territory cascade post-merge: consumer Pest tests deleted; emmie + brick-inventory-orchestrator pick up coverage automatically on next composer update. Out of scope: non-App\Http\Controllers\*namespaces (Actions/Services/Jobs/Middleware are allowed to call persistence APIs), non-Eloquent receivers, dynamic method names ($model->{$var}()— value-flow analysis), variable class names in static calls ($class::create(...)). Closes war-room enforcement queue #87. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has a controller calling Eloquent persistence APIs directly — the three territories currently running the string-match Pest test caught the bulk of these, but the type-aware shape will surface additional violations the string-match shape missed:Model::create(),Model::destroy(), chained Builder mutations,*Quietlyvariants, etc.). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the 0.4.0 release header) — three territories' Pest tests already closed the string-match-visible violators; the type-aware additional surface (Builder chains,Model::create(),*Quietlyvariants) may carry undetected violators.EnforceAuditTransactionScopeRule— enforces ADR-0029 (Audit Row Durability Contract) §Decision rule 3. Flags non-transactional state mutations (StatefulGuard/Session/Cache/Bus/Queue/Mailer/Notification/Broadcaster/Filesystemand theirIlluminate\Support\Facades\*counterparts, mutation methods only) insidetransaction(...)closures inApp\Actions\*classes. Identifier:enforceAuditTransactionScope.nonTransactionalMutationInClosure. Doctrine: ADR-0029 §Decision rule 3. Seed: ISMS-0003 PR #7 commitf1d357b(2026-05-28) — three Auth Actions (AuthenticateWorkerAction,VerifyTwoFactorChallengeAction,LogoutWorkerAction) mutatedStatefulGuard+Sessionstate inside the transaction closure before the audit row write; an audit-write failure would have rolled back the audit row while leaving the session/guard mutation intact (A.8.15 / A.5.33 violation). Reads (Auth::user(),Session::get(),Cache::get(), etc.) are deliberately permitted — only mutations carry the rollback-vs-side-effect asymmetry. Instance-call detection matches the constructor-property's declared FQCN against the blocklist keys; static-call detection resolves the facade name viaScope::resolveName(). Nestedtransaction(...)calls inside an outer closure are walked transitively — a nested mutation is still inside the outermost transaction's rollback scope; top-level transaction discovery deduplicates so each violation reports exactly once. Out of scope: manual transaction management (DB::beginTransaction()/commit()); non-App\Actions\*namespaces; the failure-side discipline (sentinel-return; throw-inside-closure detection) which lives as per-territory Pest arch tests under enforcement queue #85. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has anApp\Actions\*class mutating non-transactional state inside atransaction(...)closure). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the 0.4.0 release header) — ISMS-0003 PR #7 commitf1d357balready closed ISMS's known violators; other consumer territories may carry undetected violators.EnforceFormRequestToDtoRule— flags concrete classes extendingIlluminate\Foundation\Http\FormRequestthat neither declare nor inherit atoDto()method. Without the method, controllers hand$request->validated()arrays to Actions — untyped, key-renameable, and invisible to static analysis; the typed-DTO handoff is the ADR-0012 contract. Doctrine: ADR-0012 (FormRequest → DTO Flow). Identifier:enforceFormRequestToDto.missingToDtoMethod. Promoted from entreezuil's reflection-based Pest arch test (tests/Arch/FormRequestsTest.php, "form requests with mutation actions define toDto method") — the second instance of the "arch test detects misuse but not omission" enforcement shape under war-room enforcement queue #55 (Commander dispositioned the Phase-2 promotion 2026-05-07; war-room board WR-0066). Sister ofEnforceResourceDataValidatorOptInRule(queue #55 instance 3, PR #20) — same opt-in-omission pedagogy, same parameterized-base shape. kendo carries only the weaker misuse-only form; the stronger entreezuil omission semantic is what ships here. Inheritance is matched via PHPStan reflection (FQCN ancestor traversal) — short-name collisions in unrelated namespaces do NOT match. The base FQCN is parameterizable via theformRequestBaseClassPHPStan parameter (default:Illuminate\Foundation\Http\FormRequest); territories can narrow the contract to a territory-local base per consumerphpstan.neon. Abstract classes are exempt (the per-territoryBaseFormRequestintermediate is not a mutation request); inherited and trait-providedtoDto()declarations satisfy the contract (mirroring the source-of-truth Pest test'smethod_exists()matcher). Legitimately DTO-less requests (entreezuil precedent:LoginRequest, whose auth flow callsAuthManager::attempt()directly) are suppressed per consumerphpstan.neonignoreErrorskeyed on the identifier — never by name inside the rule, per the package convention. Versioning: per ADR-0021 §Versioning, candidate Major bump (the rule surfaces new errors in already-clean code wherever a consumer territory has a concrete FormRequest without atoDto()method — read-only/query requests included until suppressed). Pre-cascade audit required across ISMS, kendo, emmie, entreezuil, ublgenie, brick-inventory, codebook at per-territory Phase-B bump (v0.4.0 tags known-dirty — see the 0.4.0 release header) — entreezuil's Pest arch test already closed its own violators, but every other consumer territory enforces at most the weaker misuse-only shape and may carry undetected omissions. Sister extraction for the routes->can()middleware omission shape (queue #55 instance #1, WR-0067) remains deferred to a separate dispatch.
-
EnforceCurrentUserAttributeRule— corrected the controller-detection gate from an ancestry check (ClassReflection::isSubclassOf(Illuminate\Routing\Controller)) to a namespace prefix check ($scope->getNamespace()starts withApp\Http\Controllers). The ancestry gate was a silent no-op: every consumer territory (kendo, ublgenie, entreezuil) ships base-lessfinalcontrollers with noextends Controller, so the ancestry walk matched zero controllers and the rule enforced nothing. The namespace gate mirrors the siblingForbidEloquentMutationInControllersRuleand the canonical "controllers are identified by theApp\Http\Controllersnamespace" convention. Caught by the agent-review sweep on PR #26 (reviewpullrequestreview-4401182606). Regression-proofed by a base-lessfinalcontroller fixture (RequestUserInBaselessController— flagged;CurrentUserAttributeInBaselessController— clean) that reproduces the exact real-world shape the rule missed. Versioning: false-negative closure — the rule now actually fires where it always intended to; this is part of the same[Unreleased]Major bump the rule's addition already carries (consumers with un-migrated controllers will now see the errors). No new identifier; no consumerignoreErrorsmigration shape change. -
EnforceFormRequestToDtoRule/EnforceResourceDataValidatorOptInRule— corrected the shippedextension.neonparameter defaults from double-backslash single-quoted FQCNs ('Illuminate\\Foundation\\Http\\FormRequest','App\\Http\\Resources\\ResourceData') to single-backslash ('Illuminate\Foundation\Http\FormRequest','App\Http\Resources\ResourceData'). NEON only unescapes\\inside double-quoted strings; in single quotes (and unquoted)\\stays two literal characters, so the parameter decoded to a 4-segment double-backslash class name that matches no real class. The effect:ClassReflection::isSubclassOf()returned false for every analysed class and both rules were silent no-ops for any consumer registeringextension.neonwithout an explicit parameter override — CI stayed green because every PHPUnit test, the coverage gate, and Infection construct the rule directly via the PHP::classconstructor default (PHP single-quoted strings do collapse\\), never exercising the NEON registration path. Verified empirically end-to-end: shipped default →[OK] No errorson theViolatorRequest/ViolatorResourcefixtures; single-backslash → fires with the exact expected message/line. TheresourceDataBaseClassdefect is pre-existing (shipped in PR #20, v0.3.0) —EnforceResourceDataValidatorOptInRulehas been a no-op for default-configured consumers since release; fixed here in the same edit rather than deferred sinceEnforceFormRequestToDtoRuleintroduced the identical defect on the line above. Caught by the agent-review sweep on PR #33 (jasperboerhof BLOCKER). Regression-proofed by a container-resolved test per rule (testRuleResolvesFromExtensionNeonAndFires— resolves the rule from the PHPStan container viagetAdditionalConfigFiles()+getByType(), exercising the NEON default and%parameter%wiring, then asserts the violator fires; both tests confirmed to fail when the double-backslash defect is reintroduced). An inline NEON-quoting warning comment now guards both defaults inextension.neon. Versioning: forEnforceFormRequestToDtoRule, this is part of the same[Unreleased]candidate-Major the rule's addition carries (the rule now actually fires in consumers). ForEnforceResourceDataValidatorOptInRule, the v0.3.0 release that shipped the rule was a no-op for default consumers; restoring enforcement surfaces previously-undetected violations on^0.3consumers without a parameter override — the pre-cascade audit demanded for both rules must now treat the resource-data rule as effectively un-enforced until this fix ships.
- Tests:
EnforceFormRequestToDtoRulegains two fixture-backed coverage additions closing documented-but-unpinned semantics:TraitProvidedToDtoRequest(a concrete request whosetoDto()arrives via a trait — pins the trait leg of themethod_exists()parity promise, which routes through PHPStan'shasNativeMethod()trait flattening) andTransitiveViolatorRequest(a concrete leaf extending the abstractAbstractBaseRequestwith notoDto()anywhere in the chain — pins transitive-violation detection through an intermediate abstract layer, the inverse of the existing inherited-compliant case). Both raised in the PR #33 review (jasperboerhof MINOR + General-review concern). Test-only; no consumer-facing surface. Versioning: none (test coverage).
- CI: pinned
symfony/consoleto^7.2inrequire-dev.symfony/console8.x (v8.1.0, released 2026-05-29) breaks Infection 0.33.x's mutation runner — its DI container referencesSymfony\Component\Console\Helper\QuestionHelperas a service Symfony Console 8 no longer registers that way, socomposer mutation:ciaborts withUnknown serviceand exits 1. Because the package'scomposer.lockis gitignored, CI resolves dependencies fresh on every run;illuminate/*v13 permits Symfony 8, so the resolver began pulling v8.1.0 and the mutation gate went red fleet-wide (PRs green on 2026-05-28 turned red on 2026-05-29 with no source change). The pin holds the dev toolchain atsymfony/consolev7.4.x — verified mutation gate green (Covered Code MSI 81% ≥ 75) — until Infection ships Symfony Console 8 support, at which point this constraint should be widened or removed. Versioning: none (dev-only test-infra; no consumer-facing surface).
- README: added a Production dependencies section documenting why the
illuminate/*chain (database,contracts,cache,filesystem,log,mail) lives inrequire, notrequire-dev. The rules andConnectionTransactionReturnTypeExtensionreflect against Illuminate contracts/classes at analysis time, so they are genuine analysis-time (runtime-for-the-extension) dependencies; moving them torequire-devwould break consumers analysing non-Laravel or partial trees. Documents the architectural intent (Sapper M1 Finding #4, Form A — keep + document, do not move). Versioning: none (docs only).
0.3.0 — 2026-05-13
Release-as-a-whole: MAJOR — collapses three rule-level contractual widenings into a single Major bump per ADR-0021 §Versioning. Each rule's pre-cascade audit returned 0 violators across all 5 consumer territories (kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator), so the Major represents the contract change, not empirical violation count. Consumers upgrading from ^0.2 to ^0.3 accept the broader rule contracts whether or not their existing code trips them. Phase A pin sweep (^0.1.x → ^0.2) closed pre-release — all four laggard consumers (kendo, entreezuil, emmie, BIO) bumped between 2026-05-06 and 2026-05-08 via independent dispatches; verified by 4-territory Medic wave 2026-05-13 (all no-op, all composer phpstan clean against EnforceAuditSnapshotOnRetryRule). Phase B pin sweep (^0.2 → ^0.3) follows post-tag as a separate war-room dispatch.
EnforceResourceDataValidatorOptInRule— flags classes extendingApp\Http\Resources\ResourceDatathat declare a non-emptyEAGER_LOAD_COUNTorEAGER_LOAD_SUMconstant but do not callvalidateRelationsLoaded()anywhere in their method bodies. Without the call, missing eager-load aggregates fail open as0/nullinstead of throwing — silently re-introducing the silent-zero bug closed by kendo PR #1079 (KD-0494). Doctrine: ADR-0009 §EAGER_LOAD validator opt-in. Identifier:enforceResourceDataValidatorOptIn.missingValidatorCall. Promoted from kendo PR #1084's Pest arch test (Armorer, merged 2026-05-07 atdb20ea9cf) — the third instance of the "arch test detects misuse but not omission" enforcement shape, dispositioned for Phase-2 promotion under war-room enforcement queue #55 by the Commander on 2026-05-07. Inheritance is matched via PHPStan reflection (FQCN ancestor traversal) — short-name collisions in unrelated namespaces do NOT match. The base FQCN is parameterizable via theresourceDataBaseClassPHPStan parameter (default:App\Http\Resources\ResourceData); territories whoseResourceDatalives elsewhere can override per consumerphpstan.neon. Compliant call shapes:self::validateRelationsLoaded($model),static::validateRelationsLoaded($model),$this->validateRelationsLoaded($model)(instance form accepted for liberal compatibility with the source-of-truth Pest matcher, even though the base method isprotected static). Empty-array constants (EAGER_LOAD_COUNT = []) do NOT fire — they are no-ops. Versioning: Minor-at-rule-level, collapses into the bundled v0.3.0 Major per ADR-0021 §Versioning. Cross-territory cascade audit (2026-05-08): 0 violators across emmie, kendo, entreezuil, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-08-pre-cascade-audit-resource-data-validator-opt-in.md. Side observations: emmie usesApp\Http\Resources\DTOResource(non-default base, rule non-applicable absentresourceDataBaseClassoverride); entreezuil has not adopted theResourceDatapattern (still onJsonResourcedespite ADR-0009 in CLAUDE.md, latent adoption debt); BIO operates dual-base (ResourceData+ComputedResourceData<TSource>per BIO sovereign ADR-0010). Sister extractions for the FormRequesttoDto()omission shape (queue #55 instance #2) and the routes->can()middleware omission shape (queue #55 instance #1) are deferred to separate dispatches.
- Pinned all GitHub Actions references in
ci.ymlandrelease.ymlto commit SHAs with# v<MAJOR>comments for Dependabot tag-tracking. Closes Sapper M1 Finding #3 (supply-chain forward-compatibility before potential Packagist OIDC migration). Versioning: none (CI workflow change, no consumer-facing surface).
- Doctrine: corrected publish-channel framing in
CLAUDE.md(L11 and the Release process section) and therelease.ymlheader comment. Public packagist.org has no OIDC Trusted Publishing option today — OIDC is a Private Packagist–only feature (packagist/artifact-publish-github-action, GA February 2026). The package's actual publish channel is the standardhttps://packagist.org/api/githubpush-event webhook (dev-*aliases on branch push, versioned releases on tag push viarelease.yml). Migration to Private Packagist would change ally-side Composer consumption (private repo URL + token incomposer.json) and is a commercial decision; tracking continues on Issue #11. Closes Sapper M1 Finding #2 (doctrine drift on publish channel) and resolves Issue #11 audit. Versioning: none (doctrine alignment, no consumer-visible behaviour). - Governance: added
.github/CODEOWNERSrouting all changes to@script-development/phpstan-warroom-admins. A separate rule-authors team is intentionally not split out today — the admins team and the rule-design reviewer set are identical at the current shop size; revisit if the contributor base grows or rule-design review becomes a distinct concern from operational repo administration. Pairs with branch-protection update enablingrequire_code_owner_reviews=true. Closes Sapper M1 Finding #5 (no CODEOWNERS file). Versioning: none (governance change, no consumer-visible surface). LogRule(BREAKING): extended to cover the static-call shapesModel::destroy(...)andModel::forceDestroy(...)on Log-named classes.getNodeType()broadened fromMethodCall::classtoCallLike::classandprocessNodebranches onMethodCallvsStaticCall. Both shapes emit the samelogRule.logModificationidentifier so consumerphpstan.neonignoreErrorsentries cover the whole rule with one identifier (the previous rule's compliance teeth depended ondelete/forceDeleteinstance shapes; on a non-soft-delete log modelModel::destroy([1])purges andModel::forceDestroy([1])always purges — both slipped through). Versioning: Major-at-rule-level per ADR-0021 §Versioning; ships asv0.3.0. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-13-pre-cascade-audit-log-rule-static-call.md. The static-call shape proved cleaner than v0.2.0's instance-call expansion (which surfaced 1 operational-log false positive in ublgenie); no consumer-sideignoreErrorsmigrations required. Resolves issue #4.LogBuilderTruncateRule(BREAKING): new sibling rule toLogRule, sharing thelogRule.logModificationidentifier so consumerphpstan.neonignoreErrorsentries cover the whole append-only doctrine with one entry. FlagsBuilder->truncate()calls where the fluent chain's most recenttable()invocation targets a Log-named table (string-literal first argument containing'log'/'logs', case-insensitive substring match). CoversDB::table('logs')->truncate(),DB::connection('central')->table('logs')->truncate(), and$this->db->table('logs')->truncate()(instance-injectedConnectionInterface). Receiver detection is type-based (Illuminate\Database\Query\BuilderORIlluminate\Database\Eloquent\Buildersubtype viaObjectType::isSuperTypeOf()) — mirrorsEnforceAuditSnapshotOnRetryRule'sConnectionInterfacepattern. The Eloquent\Builder receiver branch covers the rare-but-coherent$eloquentBuilder->table('logs')->truncate()shape; Eloquent chains that set the table via the Model's$tableproperty (AuditLog::query()->truncate()) or via Eloquent'sfrom()vocabulary (AuditLog::query()->from('logs')->truncate()) are an acceptable miss in the same family as variable table names — the table name does not appear as atable()-call string-literal in the chain. Doctrine: ADR-0001 §Append-only —truncate()is the bluntest delete and bypasses Eloquent events, observers, and audit triggers entirely. Out of scope: variable table names ($t = 'logs'; DB::table($t)->truncate()), Eloquentfrom('logs')chains, and Model-property-driven tables — all would need value-flow or model-graph inspection; acceptable misses, rely on reviewer + consumer-sidephpstan.neonignoreErrors. Versioning: Major-at-rule-level per ADR-0021 §Versioning; collapses into the bundled v0.3.0 Major alongside theLogRulestatic-call expansion. Cross-territory cascade audit (2026-05-13): 0 violators across kendo, entreezuil, emmie, ublgenie, brick-inventory-orchestrator — campaign report atcampaigns/phpstan-warroom-rules/2026-05-13-pre-cascade-audit-log-builder-truncate.md. Thetruncate()shape proved genuinely uncommon across the fleet (~6 calls total across 2,500+ scanned PHP files; none against log-named tables); no consumer-sideignoreErrorsmigrations required. Resolves issue #8.- CI: added PHP 8.5 to the
ci.ymlandrelease.ymltest matrices alongside 8.4 (['8.4']→['8.4', '8.5']). PHP 8.5.0 was released 2025-11-20; the war-room dev environment already runs 8.5.5 locally, so PRs were getting ad-hoc 8.5 coverage during pre-push but no CI signal. Adding (rather than replacing) keeps 8.4 — thecomposer.json^8.4contractual minimum — covered.shivammathur/setup-php@v2supports 8.5 since GA. Resolves issue #5. - CI: added line-coverage measurement and a threshold gate.
ci.ymlswitchescoverage: none→coverage: pcovon both 8.4 and 8.5 matrix legs (PCOV is line-coverage-only and faster than Xdebug — debugger features aren't needed). New composer scripts:test:coverage(runs PHPUnit with--coverage-clover=build/logs/clover.xml --coverage-text) andcoverage:check(runsbin/coverage-check.php, a standalone clover parser — no extra runtime dependency added to a static-analysis package for a single CI gate). Two new CI steps replace theTestsstep: Tests with coverage and Coverage threshold gate. Clover XML is uploaded as a per-leg artifact (clover-php-${{ matrix.php }}, 14-day retention) so reviewers can inspect uncovered lines without spelunking through workflow logs. Initial threshold: 83% — the measured baseline is 83.92% (240/286 lines acrosssrc/), set 0.92 percentage points lower to absorb trivial fluctuation on equivalent-but-renamed code. Class coverage (0/6) and method coverage (39%) are intentionally unmeasured by the gate v1; per the issue's deliberation, line coverage is the right v1 signal and branch/method coverage is a follow-up after the line gate is bedded in. The 16-percentage-point gap to 100% audits as defensive guard clauses on unexpected node shapes (the kind of branch the issue itself flagged as "genuinely hard to fixture" —LogRule's static-call branch falls back when$node->classisExprrather thanName); a follow-up issue will audit and ratchet the threshold upward to 90%+. Versioning: none (pure CI/test-infra, no consumer-visible behaviour). Resolves issue #9. - CI: added Infection mutation testing gate, layered on top of the line-coverage gate. New
infection/infection ^0.32.7dev dependency,infection.json5config (@defaultmutator profile,src/source scope, fixtures stay out via PHPUnit's existing<source>block,--testsuite=Rules), and two new composer scripts:mutation(local,--threads=max --show-mutationsfor inspecting escaped mutants) andmutation:ci(CI:--threads=4 --no-progress --logger-github --min-msi=75 --min-covered-msi=75— GitHub annotations on escaped mutants surface inline in PR diffs). Two new CI steps after the coverage gate: Mutation testing and Upload mutation report (per-leginfection-php-${{ matrix.php }}artifact, 14-day retention).composer config allow-plugins.infection/extension-installer truewas set to permit the framework-adapter installer plugin. Initial thresholds: 75% MSI and 75% Covered Code MSI — measured baseline is 78.5% MSI (241 killed / 307 mutants, 100% Mutation Code Coverage), set 3.5 percentage points lower to absorb mutator-shape fluctuation on equivalent code. Same shape as the line-coverage gate: lock in current state, audit gaps, ratchet upward. The 22% surviving-mutant population audits as a mix of (a) genuinely-equivalent mutants the issue itself anticipated —mb_stripos↔striposon PSR-4 ASCII-only class names inLogRule, defensive guard inversions (LogicalNot/IfNegation) on early returns that filter the same nodes by either condition — and (b) genuinely-uncovered branch logic that warrants new fixtures. A follow-up issue will audit each survivor, kill where realistic,@infection-ignore-for-mutator-annotate where equivalent, and ratchet thresholds to the issue's target of 80% MSI / 90% Covered Code MSI. Versioning: none (pure CI/test-infra, no consumer-visible behaviour;infectionisrequire-devonly). Resolves issue #10.
0.2.0 — 2026-05-04
EnforceAuditSnapshotOnRetryRule— flagsApp\Actions\*classes whose constructor injects an entity audit logger and whose$connection->transaction(...)calls do not begin with an in-memory state reset ($model->refresh(), fresh fetch via->newQuery()->findOrFail(...)/->fresh(), or fresh instantiation vianew .../->newInstance()). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. Identifier:enforceAuditSnapshotOnRetry.firstStatementMustResetState. Promoted from cross-territory Pest arch tests (emmie PR #187, entreezuil PR #139, ublgenie PR #166, kendo PR #1029). Receiver detection is type-based (Illuminate\Database\ConnectionInterfacesubtype) — replaces territory-specific property-name matching ($this->dbvs$this->connection). Escape hatch:// @audit-snapshot-retry-safety: <rationale>marker preceding the transaction call.
- PHP constraint: bumped
composer.jsonphpfrom^8.3to^8.4. The package's Pint config (mb_str_functions: true) normalizesltrim/trimcalls tomb_ltrim/mb_trim, which are PHP 8.4+ functions. The new rule introduced the firstmb_ltrim/mb_trimcallsites; aligning the constraint with the formatter's actual output. All consuming territories already run PHP 8.4 — no real-world impact. LogRule(BREAKING): extendedFORBIDDEN_METHODSfrom['delete', 'update']to['delete', 'forceDelete', 'forceDeleteQuietly', 'update']. On aSoftDeletes-bearing model->delete()is a no-op against the underlying row and->forceDelete()is the only call that actually purges; the rule's compliance teeth previously rested on the migration-time convention that audit-log models never adoptSoftDeletes. Static-call shapes (Model::destroy(),Model::forceDestroy(),DB::table('logs')->truncate()) remain out of scope —getNodeType()returnsMethodCall::class, and static-call coverage is tracked as issue #4. Origin: issue #1, surfaced by ally review on Back-to-code/ublgenie-app#163. Pre-cascade audit across emmie, kendo, entreezuil, ublgenie surfaced one new violation:ublgenie/app/Actions/DeleteBranch.php:56(InvoiceLog::query()->whereIn(...)->forceDelete()) — operational/processing log, not an audit log; migrates to consumer-sidephpstan.neonignoreErrorsper package convention. Versioning: per ADR-0021 §Versioning, this is a Major bump (new errors in code that previously passed); within 0.x this ships asv0.2.0.
0.1.1 — 2026-04-29
- Compatibility: widened
illuminate/*constraints from^11.0 || ^12.0to^11.0 || ^12.0 || ^13.0across the five required packages (database,contracts,cache,filesystem,log,mail). Surfaced during ADR-0021 cascade onto entreezuil (Laravel 13). No behavioral change — the package's PHPStan rules reason about class names that are stable across Laravel 11/12/13. Forward-looking: removes the constraint as a future cascade blocker.
0.1.0 — 2026-04-29
EnforceActionTransactionsRule— flagsApp\Actions\*classes whoseexecute()performs ≥2 writes without->transaction(). Doctrine: ADR-0011.ForbidDatabaseManagerInActionsRule— flagsApp\Actions\*constructors that injectIlluminate\Database\DatabaseManager. Doctrine: ADR-0021 §Why ConnectionInterface.ForbidAbortHelperRule— flagsabort(),abort_if(),abort_unless()function calls. Doctrine: war-room §Explicit over implicit.LogRule— flagsupdate()/delete()calls on classes whose name contains"Log"or"logs". Doctrine: ADR-0001 §Append-only.ConnectionTransactionReturnTypeExtension— resolves$connection->transaction(fn () => $foo)to the closure's return type instead ofmixed.
- Rules ported from emmie's
backend/app/PHPStan/. The territory-specificTerminologyexception inLogRulewas dropped — per-territory false positives are now suppressed via consumerphpstan.neon ignoreErrors. - Test coverage is smoke-level for v0.1.0; full matrix for
EnforceActionTransactionsRule(non-DB property exclusions, nested closure transaction detection, full 18-method write list) lands in a follow-up. - Action namespace assumption: rules that scope to Actions match
App\Actions\*. Lift to a parameter when a non-conforming territory onboards.