v0.4.0
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.
Added
- 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.
Fixed
-
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.
Added
- 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).
Changed
- 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).
Documentation
- 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).