script-development / phpstan-warroom-rules
Canonical PHPStan rules enforcing war-room doctrine across script-development Laravel territories.
Package info
github.com/script-development/phpstan-warroom-rules
Type:phpstan-extension
pkg:composer/script-development/phpstan-warroom-rules
Requires
- php: ^8.4
- illuminate/cache: ^12.0 || ^13.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/database: ^12.0 || ^13.0
- illuminate/filesystem: ^12.0 || ^13.0
- illuminate/log: ^12.0 || ^13.0
- illuminate/mail: ^12.0 || ^13.0
- nikic/php-parser: ^5.0
- phpstan/phpstan: ^2.0
- psr/log: ^3.0
Requires (Dev)
- infection/infection: ^0.34.0
- laravel/pint: ^1.18
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.1
- v0.6.0
- v0.5.0
- v0.4.0
- v0.3.0
- v0.2.0
- v0.1.1
- v0.1.0
- dev-release/v0.9.0
- dev-feat/forbid-ad-hoc-date-parsing-rule
- dev-feature/queue217-forbid-credential-cast-bypass
- dev-feat/psr-0002-sentinel-fallback-rule
- dev-armorer/queue140-raw-exception-message-in-response-rule
- dev-fix/eloquent-mutation-local-receivers
- dev-armorer/queue112-issubclassof-deprecation
- dev-war-room/claude-md-version-resync
- dev-war-room/release-0.8.0
- dev-war-room/f2-commit-composer-lock
- dev-ci/ci-passed-aggregate
- dev-queue-136-137-action-result-dto-and-inline-jsonresponse-rules
- dev-chore/release-v0.7.0-changelog
- dev-feat/enforce-audit-model-protections-rule
- dev-feat/configurable-controller-namespace-prefixes
- dev-fix/enforceformrequesttodto-accept-todtos
- dev-ci/crier-2approval-consensus
- dev-ci/town-crier-reannounce-on-push
- dev-ci/town-crier-resolve-on-unlabel
- dev-ci/town-crier-producer
- dev-engineer/adr-projection-resync-wr-0036
- dev-release/v0.4.0-prep
- dev-engineer/transaction-extension-test-and-deps-doc
- dev-chore/v0.3.0-changelog-recovery
- dev-engineer/log-builder-truncate-rule
- dev-chore/release-v0.3.0
- dev-engineer/doc-edit-bundle
- dev-ci/infection-mutation-gate
- dev-feat/logrule-static-call-coverage
This package is auto-updated.
Last update: 2026-09-09 14:00:22 UTC
README
Canonical PHPStan rules enforcing war-room doctrine across script-development Laravel territories.
Distributed via Composer as script-development/phpstan-warroom-rules. Doctrine source is ADR-0021.
Why
Several doctrine claims need static-analysis enforcement that out-of-the-box PHPStan + Larastan cannot provide:
- Multi-write Actions must wrap operations in a database transaction.
- Audit log records are append-only.
- The
abort()family of helpers is forbidden in favor of explicit HTTP exception throws. - Action constructors inject
ConnectionInterface, neverDatabaseManager.
These rules originated inside emmie and have been promoted to a shared package so every consuming territory gets the same enforcement on composer require.
Installation
composer require --dev script-development/phpstan-warroom-rules
The package ships with phpstan/extension-installer metadata. If you have the installer, the extension is auto-loaded. Otherwise, add it to your phpstan.neon:
includes: - vendor/script-development/phpstan-warroom-rules/extension.neon
Rules
| Rule | Identifier | Detects | Forbids / Requires |
|---|---|---|---|
EnforceActionResultDtoRule |
enforceActionResultDto.arrayReturnFromExecute |
The execute() method of App\Actions\* classes (namespace prefix) |
Declaring an array native return type (bare array, ?array, a union/intersection member `array |
EnforceActionTransactionsRule |
enforceActionTransactions.missingTransaction |
Action execute() methods |
If ≥2 write operations appear without ->transaction(), error. |
ForbidDatabaseManagerInActionsRule |
forbidDatabaseManager.inAction |
Action constructors | Constructor parameter typed DatabaseManager is an error. Inject ConnectionInterface instead. |
ForbidAbortHelperRule |
forbidAbortHelper.abortUsed |
Function calls | abort(), abort_if(), abort_unless() are errors. Throw an explicit HttpException subclass instead. |
ForbidHttpExceptionInActionsRule |
forbidHttpExceptionInActions.httpExceptionInAction |
throw statements inside App\Actions\* classes (namespace prefix, incl. sub-namespaces) |
Throwing a Symfony\Component\HttpKernel\Exception\HttpException-family exception (HttpException + every subclass — NotFoundHttpException, AccessDeniedHttpException, UnprocessableEntityHttpException, …) from an Action is an error. Type-aware: the thrown expression's type must be a subtype of Symfony\Component\HttpKernel\Exception\HttpExceptionInterface (catches subclasses, fully-qualified throws, and typed-value throws an import-checking arch test would miss). HTTP status concerns belong to the HTTP layer — put a uniqueness rule in the FormRequest, or throw a custom domain exception the renderer maps to a status. Illuminate\Validation\ValidationException is out of scope (not a Symfony HttpException; Actions legitimately throw it for stateful validation). Type-aware sibling of ForbidAbortHelperRule. Doctrine: war-room §Architectural Principles — Explicit over implicit (#1) + Form Request → DTO → Action pipeline (#3). |
LogRule |
logRule.logModification |
update() / delete() calls |
If the receiver type's class name contains "Log" or "logs" (case-insensitive), error. |
LogBuilderTruncateRule |
logRule.logModification |
Builder->truncate() calls |
If the fluent chain's most recent table() call targets a Log-named table (string-literal argument matching "log" / "logs", case-insensitive), error. Sibling rule to LogRule; shares the logRule.logModification identifier so a single ignoreErrors entry covers both. Eloquent from() chains and Model-$table-property-driven tables are acceptable misses. Doctrine: ADR-0001 §Append-only. |
EnforceAuditSnapshotOnRetryRule |
enforceAuditSnapshotOnRetry.firstStatementMustResetState |
App\Actions\* whose constructor injects an entity audit logger |
The first statement inside $connection->transaction(...) must reset the model's in-memory state ($model->refresh(), fresh fetch, or fresh instantiation). Doctrine: ADR-0001 §Snapshot-on-Retry Safety. |
EnforceAuditTransactionScopeRule |
enforceAuditTransactionScope.nonTransactionalMutationInClosure |
App\Actions\* whose execute() calls transaction(...) with a literal closure |
Mutating StatefulGuard / Session / Cache / Bus / Queue / Mailer / Notification / Broadcaster / Filesystem state (or their Illuminate\Support\Facades\* counterparts) inside the closure is an error. Reads (Auth::user(), Session::get(), Cache::get()) are permitted. Doctrine: ADR-0029 (Audit Row Durability Contract) §Decision rule 3. |
ForbidEloquentMutationInControllersRule |
forbidEloquentMutationInControllers.eloquentMutationInController |
App\Http\Controllers\* (including sub-namespaces; configurable via controllerNamespacePrefixes) |
Calling Eloquent persistence APIs (save, update, delete, create, destroy, forceDelete, forceFill, push, restore, touch, and their *OrFail / *Quietly / *OrCreate variants — 24-method blocklist) on Illuminate\Database\Eloquent\Model subclasses or Illuminate\Database\Eloquent\Builder chains is an error. Reads (find, where, get, first, paginate, pluck, count, exists, query) are permitted. Delegate mutations to an Action. Doctrine: ADR-0011 (Action Class Architecture) + ADR-0019 (Explicit Model Hydration). |
ForbidResourceWrappedInJsonResponseRule |
forbidResourceWrappedInJsonResponse.resourceWrapped |
response()->json(...) (the helper FuncCall receiver, matched by AST shape) and new Illuminate\Http\JsonResponse(...) in any code whose NAMESPACE starts with a controllerNamespacePrefixes entry (default App\Http\Controllers) — the scope is the namespace, not the enclosing class, so a namespaced helper function there is in scope too — when the FIRST argument's resolved type is a subtype of Illuminate\Http\Resources\Json\JsonResource |
Wrapping a JsonResource in an explicit JSON response is an error — a resource is already Responsable, so the wrap double-wraps the payload and discards the resource's own response shaping; return the resource directly (return XxxResource::fromModel($model);). Type-aware, deliberately: a plain array / DTO / scalar / message envelope and response()->json(null, 204) are silent, and a resource nested under a named key (response()->json(['registrations' => Resource::collect(...)])) is a deliberate envelope whose first argument is an array, so it stays silent by design. Doctrine: war-room §Explicit over implicit (#1) + ADR-0009 (resources own their own serialization). Shares controllerNamespacePrefixes with ForbidEloquentMutationInControllersRule and EnforceCurrentUserAttributeRule. |
ForbidInlineArrayJsonResponseInControllersRule |
forbidInlineArrayJsonResponseInControllers.arrayPayload |
App\Http\Controllers\* (including sub-namespaces; configurable via controllerNamespacePrefixes) |
Constructing the base Illuminate\Http\JsonResponse — exact-FQCN, NOT subclasses — or its response()->json(...) factory twin with an array payload is an error. Type-aware: fires when the first argument's resolved type isArray()->yes(), catching both inline literals (new JsonResponse(['enabled' => …])) and array-typed variables (new JsonResponse($result) — the same violation laundered through a variable). Passes on Resource / DTO / JsonSerializable / mixed / unknown payloads, null (new JsonResponse(null, 204)), no-args, and any JsonResponse subclass (NoContentResponse, … — the compliant fix; matching by supertype would criminalize it). Response shapes belong to a Resource/ResourceData or a dedicated JsonResponse subclass. Deliberate miss: JsonResponse::fromJsonString(...). Sibling/inverse of ForbidResourceWrappedInJsonResponseRule (same JsonResponse × payload boundary, opposite direction — that rule fires on a Resource payload, this one on an array). Doctrine: ADR-0009 (Unified ResourceData Pattern). Seed: kendo PR #1653. |
ForbidRawExceptionMessageInResponseRule |
forbidRawExceptionMessageInResponse.rawMessageInResponse |
Calls to a configured client-facing response sink (default Laravel\Mcp\Response::error; add more via rawExceptionMessageSinks) |
Passing a raw Throwable::getMessage() — directly or via string concat ('x: ' . $e->getMessage()) — or the Throwable itself into a response sink is an error: it leaks internal detail (stack traces, SQL, file paths) to the API client. Log the raw message server-side (Log:: / report()) and return a stable, app-authored message. Type-aware: only a getMessage() on an actual \Throwable receiver fires ($validator->getMessage() is silent). Never flags server-side logging — Log:: / logger()-> / PSR LoggerInterface log-level calls and report() are the remediation, not the leak. Exempt a proven-safe app-authored message per exception CLASS via safeMessageExceptionClasses (arch-test-pinned; message only — the Throwable itself still fires) or per call site with a // @leak-safe: <rationale> comment on/above the sink line. Doctrine: war-room §Explicit over implicit (#1); information-disclosure hardening. |
EnforceResourceDataValidatorOptInRule |
enforceResourceDataValidatorOptIn.missingValidatorCall |
Classes extending App\Http\Resources\ResourceData |
If the class declares a non-empty EAGER_LOAD_COUNT / EAGER_LOAD_SUM constant but never calls validateRelationsLoaded() in any method, error. |
EnforceFormRequestToDtoRule |
enforceFormRequestToDto.missingToDtoMethod |
Concrete classes extending Illuminate\Foundation\Http\FormRequest |
If the class neither declares nor inherits a toDto() method, error. Abstract intermediates (BaseFormRequest) are exempt. Hand Actions a typed DTO, not $request->validated() arrays. Doctrine: ADR-0012 (FormRequest → DTO Flow). |
EnforceCurrentUserAttributeRule |
enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser |
Request::user() / Auth::user() / auth()->user() calls inside App\Http\Controllers\* classes (namespace prefix, incl. sub-namespaces; configurable via controllerNamespacePrefixes) |
Use #[\Illuminate\Container\Attributes\CurrentUser] User $user on the method parameter. Scope is decided by namespace, not class ancestry — a base-less final controller in App\Http\Controllers fires; FormRequests (App\Http\Requests), middleware (App\Http\Middleware), services, Actions (App\Actions), jobs, and console commands are silent because their namespaces do not start with the controller prefix (container-attribute injection does not apply to FormRequest methods regardless). |
EnforceCurrentUserAttributeRule |
enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser |
Request::user() / Auth::user() / auth()->user() calls inside App\Http\Controllers\* classes (namespace prefix, incl. sub-namespaces) |
Use #[\Illuminate\Container\Attributes\CurrentUser] User $user on the method parameter. Scope is decided by namespace, not class ancestry — a base-less final controller in App\Http\Controllers fires; FormRequests (App\Http\Requests), middleware (App\Http\Middleware), services, Actions (App\Actions), jobs, and console commands are silent because their namespaces do not start with the controller prefix (container-attribute injection does not apply to FormRequest methods regardless). |
EnforceAuditModelProtectionsRule |
enforceAuditModelProtections.hasFactoryForbidden / .softDeletesForbidden / .updatedAtNotDisabled |
Eloquent models recognised as audit records by SHAPE — short name ends with a configured suffix (default AuditLog) OR FQCN sits under a configured namespace (default App\Models\Audit) |
Three append-only protections, each firing independently: using HasFactory (a factory is a direct-insert path bypassing the hash-chained writer), using SoftDeletes (audit rows are never removed), or not disabling updated_at (an audit row is written once and never mutated — declare public const UPDATED_AT = null;) is an error. Discovery is by pattern, never a hand-maintained class list — a denylist inversion, so a newly-added audit model cannot escape the protections by omission. Abstract intermediates are exempt (their concrete leaves carry inherited violations). Non-model classes named *AuditLog are excluded by the Eloquent Model type gate. Doctrine: ADR-0001 §Append-only. |
ForbidUntimedHttpClientRule |
forbidUntimedHttpClient.missingTimeout |
A fluent Laravel HTTP-client chain that reaches a terminal send verb (get / post / put / patch / delete / head / send) from either entry point — the Illuminate\Support\Facades\Http facade or an injected Illuminate\Http\Client\Factory (anchored by TYPE, so the property alias is irrelevant) — with the whole chain visible in one expression |
Sending without an explicit request timeout is an error: the chain must carry ->timeout(...), or a ->withOptions(...) whose options type provably carries a 'timeout' key (TYPE-aware — a variable holding a literal array is seen through). connectTimeout() alone is NOT sufficient (it bounds the handshake, not the response). Conservative by design — declines rather than guessing: split chains ($req = $this->http->timeout(5); $req->get(...), or a PendingRequest-typed root, since the timeout may have been set upstream), withOptions($computed) whose type is not a constant array, chain members outside the known PendingRequest builder surface (Macroable extensions such as Http::github(), when() / unless()), raw GuzzleHttp\Client construction, vendor SDKs, and DI-bound pre-timed clients. No parameters. The AST-aware successor to the per-territory ExternalHttpTimeoutTest named-list Pest tests, which detect wrong-shape on enrolled classes but are blind to OMISSION. Doctrine: war-room §Architectural Principles #8 (explicit timeouts on external HTTP calls). Seed: war-room enforcement queue #58. |
ForbidCredentialCastBypassRule |
forbidCredentialCastBypass.castBypassedByBuilderWrite |
Write calls (update, insert, insertOrIgnore, insertGetId, upsert, updateOrInsert, updateFrom, insertOrIgnoreReturning, incrementOrCreate, and the increment family loud and quiet) whose receiver is an Illuminate\Database\Eloquent\Builder, Illuminate\Database\Query\Builder, or Illuminate\Database\Eloquent\Relations\Relation |
Naming a column that carries a hashed, encrypted or encrypted:* cast as a key in the write payload is an error. Casts fire on the MODEL path only; a builder write delegates to toBase()->update() and ships the raw value to SQL — no hash, no encryption, no exception, and a green test suite. The model path is structurally silent (a Model receiver never matches), and so are the builder methods that route through a model (create, updateOrCreate, firstOrCreate, createOrFirst) — they are the remediation. The increment family is included because Query\Builder::incrementEach() is literally update(array_merge($columns, $extra)) — its extra payload is an ordinary uncast write — and for that family a Model receiver is in scope too: Model::__call() re-exposes the protected increment methods, and Model::incrementOrDecrement() casts the in-memory attribute via forceFill($extra) while passing the same $extra uncast to the query builder. Payload arguments are addressed by parameter NAME as well as position, since increment('votes', extra: [...]) puts the payload at index 1 rather than 2. The model comes from the builder's/relation's generic type argument, read per UNION branch so `Builder |
ForbidAdHocDateParsingRule |
forbidAdHocDateParsing.stringParsedOutsideBoundary |
Construction of a date/time value from a STRING outside the configured boundary namespaces (dateParsingNamespaces, default App\Support\Time / App\Support\DateTime / App\Casts) |
Three shapes are errors: a static call whose class resolves to a DateTimeInterface subtype (Carbon\Carbon, Carbon\CarbonImmutable, Illuminate\Support\Carbon, \DateTime, \DateTimeImmutable, any subclass) or to the Illuminate\Support\Facades\Date facade, with a method in parse, rawParse, parseFromLocale, createFromFormat, rawCreateFromFormat, createFromIsoFormat, createFromLocaleFormat, createFromLocaleIsoFormat, createFromTimeString, createFromDate, createMidnightDate, create, make — matched case-insensitively, because PHP dispatches Carbon::PARSE() to the same method; new on a DateTimeInterface subtype; and a function call whose callee resolves to strtotime, date_create, date_create_immutable, date_create_from_format, date_create_immutable_from_format, date_parse or date_parse_from_format — resolved, not matched on the written token, so a same-namespace helper of your own named strtotime() is silent while use function strtotime as decode; fires under the real name. One argument gate on all three shapes: the call fires only when the argument in its decoded slot is present and its type is not provably non-string — Carbon::create(2026, 9, 7), Carbon::make($carbon), Carbon::create() and date_create() are silent; Carbon::create($raw) with $raw a string, mixed, int|string or ?string fires. The slot is per verb, not argument zero: createFromFormat decodes its second parameter, createFromLocaleFormat its third, create its $year. It is read by argument name first, position second, so Carbon::create(timezone: 'Europe/Amsterdam') is silent (no date input at all) and Carbon::create(month: 1, year: $raw) fires. Decode the string ONCE at the boundary and pass the value object onward. Type-aware — an aliased import (use Carbon\CarbonImmutable as C;) fires and reports under the real class, while a local class merely NAMED Carbon does not. Deliberate misses, none of which decodes a string: a listed call whose decoded slot is absent or provably not a string (new DateTimeImmutable() is "now", createFromDate(2026, 9, 7) assembles from integers, Carbon::create(timezone: …) names no date slot at all); the clock reads now / today / yesterday / tomorrow; the createFromTimestamp* family, instance and fromSerialized (a timestamp is already an instant); createFromTime, createStrict and createSafe, which read like decoders and are not — create() parses $year and nothing else, so a $hour is a component; createStrict() declares ?int; createSafe() rejects every component that is not an int before it calls create(); instance calls on an already-decoded value (->format(), ->addDays(), ->startOfDay()); and a dynamic class expression ($class::parse()), where the receiver has no resolvable name. An unpacked argument is resolved through the array, not read as one. Carbon::parse(...$raw) carries a single AST argument whose value is the whole array, so reading it asks "is this array a string" and the analyser's confident no suppresses the finding. The slot's type is therefore taken from INSIDE the spread — by integer offset where the slot lands, stepping over an earlier constant-length spread by its known length, and by KEY where the spread is string-keyed, since parse(...['time' => $raw]) is a named-argument spread. Where the spread carries no element information (array, mixed, an unpacked request bag) it FIRES, because unknown means it may be a string — the same direction the gate takes on a bare mixed scalar. A Traversable spread is a deliberate miss: it has neither offsets nor a known length, so nothing can be said about which value reaches the slot and the rule declines rather than guessing. The method table is reflection-checked, not hand-maintained: every public static factory of Carbon\Carbon, Carbon\CarbonImmutable and Illuminate\Support\Carbon must be classified either as a decoder in the list above or as a non-decoder in the rule's NON_DECODING_FACTORIES constant, each with a stated reason, and a Carbon release that adds a factory fails the suite by name instead of opening a silent hole. The allowlist's own gate is behavioural, not a signature read: where an allowlisted factory's signature admits a string in a slot-spelled parameter, the suite CALLS it with a date string and requires the result not to be the instant parse() reads from the same string, with a genuine decoder probed the same way as the positive control. A signature check could not answer for createSafe, whose $year is untyped. A class in the GLOBAL namespace is outside every prefix and therefore fires. Doctrine: ADR-0020 Amendment 1 (Semantic Boundary Types) + ADR-0031 (instant vs wall-clock). Seed: war-room enforcement queue #222. |
EnforceActionTransactionsRule — write-method list
The rule counts the following methods as "writes":
save, saveQuietly, create, update, delete, forceDelete, sync, attach, detach, insert, upsert, updateOrCreate, firstOrCreate, push, restore, toggle, syncWithoutDetaching, syncWithPivotValues.
Calls on properties typed as non-database services (FilesystemManager, Filesystem, Cache\Repository, LogManager, LoggerInterface, Mailer) are excluded — $this->files->delete($path) does not trigger the rule.
LogRule — false positives
The rule uses substring matching on class names. It will fire on classes named Catalog, Blog, Terminology, or any business model containing log as a substring. Suppress per-territory via phpstan.neon:
parameters: ignoreErrors: - identifier: logRule.logModification path: app/Models/Catalog.php
Each ignore should carry a comment with rationale. Future versions may add an explicit allow-list parameter — file an issue if you have a recurring need.
LogBuilderTruncateRule shares the logRule.logModification identifier with LogRule. A single ignoreErrors entry keyed on logRule.logModification therefore covers both rules for the suppressed path.
EnforceResourceDataValidatorOptInRule — configurable base class
The rule scopes to classes extending App\Http\Resources\ResourceData by default. If a territory ships its abstract resource base under a different FQCN, override the resourceDataBaseClass parameter in phpstan.neon:
parameters: resourceDataBaseClass: 'App\Resources\BaseResource'
Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching — a class named ResourceData in an unrelated namespace will not be matched. Compliant call shapes are self::validateRelationsLoaded($model), static::validateRelationsLoaded($model), and $this->validateRelationsLoaded($model) — the production base method is protected static, but the instance form is also accepted for compatibility with the source-of-truth Pest arch test's permissive matcher. Empty-array constants (EAGER_LOAD_COUNT = []) do not fire — they are no-ops.
EnforceFormRequestToDtoRule — configurable base class + exemptions
The rule scopes to concrete classes extending Illuminate\Foundation\Http\FormRequest by default. To narrow the contract to a territory-local base FQCN, override the formRequestBaseClass parameter in phpstan.neon:
parameters: formRequestBaseClass: 'App\Http\Requests\BaseFormRequest'
Inheritance is matched via PHPStan reflection (FQCN ancestor traversal), not short-name matching. Abstract classes never fire — a per-territory abstract BaseFormRequest intermediate is exempt by shape, not by name. A toDto() declared on a parent class or provided by a trait satisfies the contract (mirroring the source-of-truth entreezuil Pest arch test's method_exists() matcher).
Legitimately DTO-less requests (e.g. a LoginRequest whose auth flow calls AuthManager::attempt() directly, or read-only filter/query requests) are suppressed per territory in one of two consumer-config-driven ways — never by name inside the rule.
Option A — per-file ignoreErrors (path-keyed):
parameters: ignoreErrors: - identifier: enforceFormRequestToDto.missingToDtoMethod path: app/Http/Requests/LoginRequest.php
Each ignore should carry a comment with rationale.
Option B — formRequestToDtoExemptClasses (class-keyed): a list of fully-qualified class names to skip, matched by exact FQCN. This is the class-keyed alternative to ignoreErrors — predictable across file moves, and it ports a retiring local arch test's exempt-class list into package config 1:1. Default empty ⇒ no exemptions.
parameters: formRequestToDtoExemptClasses: # login handler: auth flow calls Auth::attempt() directly, no Action DTO - 'App\Http\Requests\Auth\LoginRequest'
A consumer-supplied FQCN list is config, not a rule-body literal — the "never by name inside the rule" convention is preserved.
Retiring a local FormRequest→DTO arch test
Where a territory already enforces "every concrete FormRequest exposes toDto()" via a local Pest arch test (e.g. entreezuil's backend/tests/Architecture/FormRequestsTest.php), this rule now duplicates that invariant. To retire the local test cleanly:
- Move the arch test's exempt-class list into
formRequestToDtoExemptClassesas FQCNs. For entreezuil that is:parameters: formRequestToDtoExemptClasses: # framework Auth::attempt() path, no Action DTO - 'App\Http\Requests\Auth\LoginRequest' # intermediate base (make it `abstract` and it drops out entirely) - 'App\Http\Requests\BaseFormRequest'
- Delete the local arch test — the package rule (identifier
enforceFormRequestToDto.missingToDtoMethod) is now the single enforcement authority.
(Territory arch-test retirement is a separate follow-up dispatch, not part of shipping this option.)
EnforceCurrentUserAttributeRule — false positives
#[\Illuminate\Container\Attributes\CurrentUser] resolves the authenticated user at method-entry DI time. A controller method that resolves the user after Auth::attempt() succeeds — the canonical login handler on a guest / throttle-only route — cannot use the attribute: at method entry no user exists yet, so injection yields null and breaks login. The rule fires on any Auth::user() / $request->user() / auth()->user() inside the App\Http\Controllers namespace and cannot see routes, so it will flag these legitimate login handlers. Suppress per territory via phpstan.neon — never by name inside the rule:
parameters: ignoreErrors: - identifier: enforceCurrentUserAttribute.useAttributeInsteadOfRequestUser # login handler: Auth::user() resolves after Auth::attempt() on a guest route path: app/Http/Controllers/Auth/AuthenticatedSessionController.php
Confirmed cross-territory (n=2, 2026-06-15): entreezuil AuthenticatedSessionController::store, ublgenie AuthController::store. Each consumer adds this on its ^0.4 bump.
Configurable controller namespaces (controllerNamespacePrefixes)
The four controller-scoped rules — ForbidEloquentMutationInControllersRule, EnforceCurrentUserAttributeRule, ForbidResourceWrappedInJsonResponseRule, and ForbidInlineArrayJsonResponseInControllersRule — decide "is this class a controller?" by namespace prefix, not class ancestry (consumer controllers are base-less final classes with no extends Controller, so an ancestry walk catches nothing). The prefix set is the shared controllerNamespacePrefixes parameter, default ['App\Http\Controllers']:
parameters: controllerNamespacePrefixes: - 'App\Http\Controllers'
A class is in scope when its namespace str_starts_with any listed prefix, so canonical sub-namespaces (kendo's App\Http\Controllers\Central\*) are covered by the default automatically. The default reproduces the prior hardcoded gate byte-for-byte — leave it unset and nothing changes.
Covering sub-namespaced controllers
A territory that ships controllers outside App\Http\Controllers — e.g. emmie's App\Http\Client\Controllers and App\Http\Admin\Controllers — opts them into both rules by listing their prefixes:
parameters: controllerNamespacePrefixes: - 'App\Http\Controllers' - 'App\Http\Client\Controllers' - 'App\Http\Admin\Controllers'
All three rules then flag inline Eloquent mutations, Request::user() / Auth::user() / auth()->user() calls, and JsonResource-wrapped JSON responses in those namespaces too. Prefixes are config — no consumer namespace is ever hardcoded in a rule body, preserving the "never by name inside the rule" convention. (Each backslash is single — NEON only unescapes \\ inside double quotes; single-quoted \\ stays two literal characters and would match nothing.)
EnforceAuditModelProtectionsRule — configurable discovery (denylist inversion)
This rule is the inverse of an allowlist arch test. The Pest predecessors it supersedes (kendo tests/Arch/AuditTest.php, entreezuil tests/Architecture/AuditTest.php, ublgenie tests/Arch/AuditTest.php) enumerate audit models — by a hand-maintained FQCN list or a namespace directory sweep — and assert each lacks HasFactory / SoftDeletes / a mutable updated_at. A hand-maintained list silently exempts every future audit model added outside it. This rule scans for the audit-model shape and flags any that lacks a protection, so nothing escapes by being forgotten.
Discovery — an Eloquent Model subclass is an audit record if its short name ends with any configured suffix OR its FQCN sits under any configured namespace prefix. The two signals are a union covering both fleet strategies. Defaults:
parameters: auditModelNamespacePrefixes: - 'App\Models\Audit' # entreezuil / ublgenie convention (incl. channel logs: AuthEventLog, SmsEventLog) auditModelNameSuffixes: - 'AuditLog' # kendo *AuditLog models, scattered across App\Models + App\Models\Central
A consumer whose audit models use a different family widens either list — for example, to bring a kendo-style channel-log pair (AiOutboundLog, AiMcpLog) into scope alongside the *AuditLog entity models:
parameters: auditModelNameSuffixes: - 'AuditLog' - 'OutboundLog' - 'McpLog'
Configuration expresses patterns, never enumerated class names — no consumer class name is ever hardcoded in the rule body, and a non-model class named *AuditLog (a DTO, a service) is excluded by the Eloquent Model type gate.
Protections — three checks fire independently (a model missing several yields several errors at the class line):
| Identifier | Fires when |
|---|---|
enforceAuditModelProtections.hasFactoryForbidden |
the model uses HasFactory (transitively — an inherited trait on an abstract base counts). A factory is a direct-insert path that bypasses the hash-chained audit writer. |
enforceAuditModelProtections.softDeletesForbidden |
the model uses SoftDeletes. Audit rows are append-only and never removed. |
enforceAuditModelProtections.updatedAtNotDisabled |
the model does not declare public const UPDATED_AT = null. The framework Model base sets UPDATED_AT = 'updated_at', so a model that never overrides it keeps a mutable timestamp — an audit row is written once and never mutated. A model that disables timestamps wholesale (public $timestamps = false;) never writes updated_at at all and is recognised natively as compliant. |
Abstract intermediates (abstract class BaseAuditLog) are exempt — the concrete leaf carries any inherited violation.
Migrating off the local arch test — move the arch test's model-discovery convention into the parameters above, delete the local HasFactory / SoftDeletes / updated_at model checks, and the package rule becomes the single enforcement authority. (The append-only update() / delete() ban on *Log classes is a separate concern already covered by LogRule.) A $timestamps = false model needs no suppression — the rule recognises disabled-wholesale timestamps natively. A remaining genuine non-audit false positive is suppressed per-file via ignoreErrors keyed on the specific identifier, with a rationale comment:
parameters: ignoreErrors: - identifier: enforceAuditModelProtections.hasFactoryForbidden # seeded read-model projection, not an audit record; factory is test-only path: app/Models/Audit/SomeProjectionLog.php
ForbidCredentialCastBypassRule — resolving DB::table() writes
A builder or relation write carries its model in its type — Voucher::query()->…
is a Builder<Voucher>, $user->apiKeys()->… is a HasMany<ApiKey, …> — so the
rule finds the cast map on its own and needs no configuration.
DB::table('users')->update([...]) carries no model at all. The rule will not
guess one from the table name (an inflection guess is exactly the false-positive
source a credential-flavoured rule cannot afford), so raw-table writes are
silent by default. Opt a table in by mapping it:
parameters: credentialCastTableModels: users: 'App\Models\User' api_keys: 'App\Models\ApiKey'
Only tables whose model declares a hashed / encrypted / encrypted:* cast
are worth listing; a mapped table whose model has no credential cast changes
nothing. Single backslashes in the FQCN — NEON only unescapes \\ inside
double quotes.
Chain forms all resolve, including DB::connection('mysql')->table('users') and
any number of intermediate hops (->where(...)->limit(...)). What does not
resolve is a builder hoisted into a variable:
$query = DB::table('users'); $query->update(['password' => $plain]); // silent — see below
The walk needs the table('…') string literal, and the variable's type is a bare
Illuminate\Database\Query\Builder carrying no table name, so there is nothing
left to read. This is a limitation of the query builder's type rather than of the
walk, and it is a false negative, never a false positive.
ForbidCredentialCastBypassRule — how the cast map is resolved
Laravel builds a model's effective cast map exactly once, in
HasAttributes::initializeHasAttributes():
$this->casts = array_merge($this->casts, $this->casts());
Two halves, two different PHP rules, and the difference decides whether a write is flagged:
| Shape | Effective at runtime | Rule |
|---|---|---|
casts() on the model, or inherited with no override |
the declared map | flagged |
casts() override that does not call parent::casts() |
ONLY the override's map — the ancestor's body never runs | not flagged |
casts() override calling parent::casts() (directly, via array_merge, or via a spread) |
both, nearer wins | flagged |
$casts property, own or inherited |
the declared map | flagged |
$casts property redeclared in a child |
ONLY the child's — a property redeclaration replaces | not flagged |
both $casts and casts() on one class |
the METHOD wins, whatever the source order | per the method |
trait casts() with a class-declared casts() too |
ONLY the class's — the trait's body never runs | not flagged |
trait casts() or $casts with no class declaration |
the trait's map | flagged |
The practical consequence: cutting the casts() chain removes a cast. A
subclass that overrides casts() without calling its parent does not inherit the
parent's hashed column — at runtime or here — so a builder write to that
column is not a cast bypass, and this rule will not claim it is. If you meant to
keep the parent's casts, compose them:
protected function casts(): array { return array_merge(parent::casts(), ['api_token' => 'encrypted']); }
ForbidCredentialCastBypassRule — when the cast map cannot be read in full
Three different things can stop the rule from reading a complete cast map, and each reports under its own identifier — MISSING, FAILED and MISCONFIGURED must not arrive as the same silent outcome, and the remediation differs:
| Identifier | Cause | Fix |
|---|---|---|
forbidCredentialCastBypass.modelSourceUnreadable |
a declaring class or trait's PHP cannot be located or parsed | fix the source |
forbidCredentialCastBypass.castMapIncomplete |
the source was read, but a casts() return or a $casts default carries no array literal at all (return self::CASTS;, return $this->buildCasts();) |
restate the credential columns as literal string pairs |
forbidCredentialCastBypass.configuredModelMissing |
credentialCastTableModels maps a table to a class that does not exist (a typo, or a stale rename) |
fix the FQCN, or drop the mapping |
All three are deliberately independent of the payload: with an incomplete map, a credential column in that payload would go unreported. Suppress an identifier alone (per file or per line) if a write is known safe; the real check stays armed.
Composed and pass-through cast maps are read, not reported.
return array_merge(parent::casts(), ['password' => 'hashed']);,
return [...parent::casts(), 'password' => 'hashed']; and a bare
return parent::casts(); all resolve: the first two contribute their literal and
the parent call continues the chain walk, and the third needs no literal of its
own. None triggers castMapIncomplete. A composition mixing a readable
contributor with a dynamic one (array_merge($this->dynamicCasts(), [...]),
array_merge(parent::casts(), self::EXTRA)) reads the readable half and stays
silent about the rest — flagging it would mean flagging every model that composes
at all.
Casts added at runtime are invisible. mergeCasts() and
withCasts() declare nothing for a static analyser to read, so a column cast
only that way is a false negative. This is documented rather than diagnosed on
measured grounds: across the war-room fleet mergeCasts() appears in application
code once, inside a copy-pasted newInstance() override propagating a map the
rule already reads, and withCasts() once, on a non-credential column — so a
diagnostic keyed on those calls would produce a false positive and catch nothing.
ForbidRawExceptionMessageInResponseRule — configurable sinks + @leak-safe exemption
The rule flags a raw Throwable::getMessage() (or the Throwable itself) reaching a client-facing response sink — an information-disclosure leak. The built-in default sink Laravel\Mcp\Response::error is always armed; a consumer adds more (a persist-error setter, a MarkInvoiceFailed Action) via the rawExceptionMessageSinks parameter — a list of FQCN::method signatures, default []:
parameters: rawExceptionMessageSinks: # single backslashes — NEON keeps them literal outside double quotes - 'App\Support\InvoiceLog::recordError' - 'App\Actions\Invoice\MarkInvoiceFailed::execute'
A signature matches BOTH call forms — a static call whose resolved class equals the FQCN, and an instance call whose receiver is a subtype of the FQCN — so an injected persist sink is caught without a rule change.
Server-side logging is never flagged — Log::, logger()->, PSR LoggerInterface log-level calls, and report() are the remediation. Log the raw message; return a stable, app-authored message to the client:
} catch (\Throwable $e) { logger()->error('invoice.show failed', ['exception' => $e->getMessage()]); // fine — server-side return Response::error('Could not load the invoice.'); // fine — app-authored // return Response::error('Failed: ' . $e->getMessage()); // ERROR — raw leak }
Exempting a proven-safe exception CLASS — when a domain exception's message discipline is proven app-authored (the codebook DependentModelRelationException shape, pinned by an arch test in the consuming territory), list it in safeMessageExceptionClasses (default []) instead of annotating every call site. Type-aware — subtypes inherit the allowance; the exemption covers the message only (passing the Throwable itself still fires: __toString carries class, file, and trace regardless of message discipline). List a class here only when an arch test pins its message discipline — config without the pin is a hole, not an exemption:
parameters: safeMessageExceptionClasses: - 'App\Exceptions\DependentModelRelationException'
Exempting a proven-safe call site — when the exception message is app-authored and carries no raw payload but a class-level listing does not fit (the codebook SendCodyReportAction shape), mark it with a // @leak-safe: <rationale> comment on the sink line or in the comment block directly above it:
// @leak-safe: SendCodyReportException carries only an app-authored, payload-free message return Response::error('Report failed: ' . $e->getMessage());
The standard PHPStan inline-ignore on forbidRawExceptionMessageInResponse.rawMessageInResponse is the alternative. getTraceAsString() / __toString() and a Throwable laundered through a formatter call are deliberate v1 misses.
ForbidAdHocDateParsingRule — configuring the boundary namespaces
The rule is silent inside the namespaces where the decode is supposed to live, and fires everywhere else. Which namespaces those are is configuration, not a hardcoded carve-out:
parameters: dateParsingNamespaces: - 'App\Support\Time' - 'App\Support\DateTime' - 'App\Casts'
A namespace matches when it equals a prefix or continues one across a namespace separator, so a prefix covers its sub-namespaces naturally — App\Support\Time exempts App\Support\Time\Parsing\IsoBounds without a second entry — while a namespace that merely shares an opening substring does not: App\CastsReport is outside App\Casts and fires. A trailing backslash in a configured prefix is normalised, so App\Casts\ and App\Casts name the same boundary. Single backslashes — see the NEON-quoting note in extension.neon.
The default names the two boundary doors ADR-0020 Amendment 1 recognises:
- A dedicated decode helper. emmie ships
App\Support\DateTime\InstantParser; lokalekeuze's lands underApp\Support\Time. This is where a request string, a query parameter or a cursor becomes a value object, with the instant-vs-wall-clock question of ADR-0031 answered once and in one place. - The row-to-model cast (
App\Casts). A database column is genuinely a string arriving from outside, and the cast is the one place the application is entitled to interpret it.
A territory narrows or widens the list from its own phpstan.neon; the rule itself knows nothing about any territory's layout. Setting it to [] is legal and arms the rule everywhere — useful on a territory whose decoding already lives behind a value object, but it will flag the value object's own constructor.
Adoption on a tree with existing violations is a phpstan.neon baseline or an ignoreErrors entry on forbidAdHocDateParsing.stringParsedOutsideBoundary, drained as the parses are folded into the boundary type. The rule deliberately has no per-call-site comment exemption: a call site that needs one is a boundary namespace that has not been named yet.
Action namespace assumption
EnforceActionTransactionsRule and ForbidDatabaseManagerInActionsRule only fire on classes whose namespace starts with App\Actions. This matches the Laravel convention used in every script-development territory. Territories using a different actions namespace should open a PR to make this configurable.
Type extension
ConnectionTransactionReturnTypeExtension is registered alongside the rules. It resolves the return type of $connection->transaction(fn () => $foo) to the closure's return type instead of mixed, enabling strict typing of transaction call sites.
Production dependencies
The illuminate/* packages (database, contracts, cache, filesystem, log, mail) sit in require, not require-dev, on purpose. The rules and ConnectionTransactionReturnTypeExtension reflect against Illuminate contracts and classes (e.g. Illuminate\Database\ConnectionInterface, the cache/mail/queue facades the audit-scope rule reasons about) at analysis time — when a consumer runs PHPStan, this package's code resolves those symbols, so they are genuine analysis-time (runtime-for-the-extension) dependencies, not test-only tooling. Moving them to require-dev would omit them from a normal composer require --dev install and break consumers that analyse non-Laravel or partial trees where the Illuminate symbols are not otherwise present.
Versioning
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, tightening
LogRule's 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.
Pin to a 0.x minor version today (^0.9, the current minor); future 1.0 release will allow ^1.0 pinning. See CLAUDE.md § Versioning for the 0.x caret-semantics rationale.
License
MIT — see LICENSE.