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.
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 before tagging — 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 before tagging — ISMS-0003 PR #7 commitf1d357balready closed ISMS's known violators; other consumer territories may carry undetected violators.
- 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).
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.