script-development/phpstan-warroom-rules
Custom PHPStan rules for Laravel projects that catch script-development conventions and common pitfalls. Drop-in config, clear error messages, and fast static analysis for CI pipelines.
composer require --dev <vendor>/<package>:^0.11 — ^0.10 excludes v0.11.0, so nothing auto-adopts. Do this per territory, in its own PR, so the new errors below are attributable to this bump and nothing else.vendor/bin/phpstan analyse or your project's wrapper) and grep the two new identifiers:
forbidSentinelFallbackOnNarrowingHelper.sentinelFallbackforbidCredentialDerivedCacheKey.keyFromEncryptedAttributephpstan.neon (or the shipped ruleset). The new optional parameter narrowingHelperNamespacePrefixes (listOf(string()), default ['App']) controls the entire first rule's scope.mixed and returns ?string/?int, and look for ?? '' / ?? 0 / ?: 'unknown' immediately after a call to it. That is the exact shape the rule was written for — tc-api PR #360 seeded [''] from ?? '', read as non-empty, and unlocked a forceDelete().Don't roll the bump across the fleet at once. Each territory adopts on its own pin-bump PR, remediates, and goes green independently.
Triage each sentinel hit into one of three remediations. The rule only accepts explicit handling:
null explicitly: ?? null, and pass the null onward.// flagged
$gender = text($leaf['gender']) ?? '';
// fixed — preserve the signal
$gender = text($leaf['gender']);
if ($gender === null) { return; } // or throw, or skip the write
?? null alone is not the fix. The seed's third bug — Carbon::parse(text($leaf) ?? null) writing today's date as a person's DOB — is out of scope for this rule: ?? null is the correct half of the shape, and the damage happens in the null-swallowing sink one call later. Pair the preserved null with an early return or a throw before the sink. The rule will not catch this for you.
Drain with a baseline, then shrink it. Generate a PHPStan baseline on the first bump PR so CI goes green immediately, then work the baseline down file by file. Hit counts are small enough that full drain in one PR is usually feasible (fleet scan 2026-09-23: 8 in emmie, 6 in lokalekeuze, 0 in the other ten).
Lookups get an inline ignore, not a refactor. A helper like get(mixed $key, string $default = ''): ?string is indistinguishable from a boundary reader by signature alone — that's a known residual false positive:
/** @phpstan-ignore forbidSentinelFallbackOnNarrowingHelper.sentinelFallback */
$perPage = perPage($request->query('per_page')) ?? 25;
Alternative escape hatch for a whole helper family: narrow narrowingHelperNamespacePrefixes so the helper's namespace is out of scope. Prefer the inline ignore — it's local and auditable.
Cache keys: never derive from an encrypting cast. If a model attribute has an encrypted cast, its plaintext is a credential-equivalent; keying a cache on it leaks it into the cache store, logs, and getKey-adjacent telemetry. Use a dedicated blind-index/hash column as the cache key instead.
Prove the rule fires before you trust a clean run. The cache-key rule is silent on a consumer with no encrypted cast — a green run there is not evidence the rule is wired up. Plant a known credential-keyed cache on a scratch branch, confirm it errors, then revert.
ForbidCredentialCastBypassRule now reads casts() and parent::casts(). If you override casts in a subclass, the rule follows PHP's override order including the parent array, so a credential cast declared in a base model is still recognised. Prefer declaring casts in a casts() method over the legacy $casts property where the package's supported version allows it.
^0.10 can red on ^0.11. Budget the drain into the bump PR, don't split it out.mixed param, or making the return non-nullable) does. That's intentional — your helper inventory churns, the shape doesn't.mixed is the discriminator. get(string $key, mixed $default = null): ?string never fires, because the default is the caller's, not unvalidated external data. If you want a helper excluded, making the mixed parameter optional is a legitimate structural fix.App and App\ behave identically; Application\Foo is never swept in. Vendor helpers — including filter_var($x, FILTER_VALIDATE_EMAIL) ?? '' and every framework call — are structurally out of scope. Don't expect them to flag, and don't tighten the prefix to catch them.Vendor\Reader|App\Support\LeafReader will be flagged because PHPStan's merged method reflection only reports the first branch's declaring class; the rule resolves per-branch to compensate.?? $default, ?? $this->fetch($leaf)), the long ternary ($x !== null ? $x : '' — an explicit null test is the remediation), and helper results laundered through a variable before the coalesce. Provenance is gone at the coalesce; widening the matcher to chase these would be false-positive-rich, so don't file bugs for them. In an import/boundary layer, grep for these shapes yourself.?? null, coalesces on array offsets ($row['city'] ?? '' — the idiomatic absent-key default), helpers with no mixed param, non-nullable returns, and nullable non-scalar returns (null-object fallback is a legitimate different pattern).@phpstan-ignore housekeeping. If you set reportUnmatchedIgnoredErrors: true (you should), a stale inline ignore on the sentinel identifier will fail CI after you actually fix the code — remove ignores as you drain, or you'll trade one red for another.phpstan analyse src/Actions/FormatLogbookEntriesAction.php) and run verbosely. The rule's per-node cost on non-matching expressions is a single instanceof, so whole-codebase runs stay cheap — there is no performance reason to scope the analysis down.FormatLogbookEntriesAction, one a CarbonImmutable::parse(… ?? 'now') DOB shape) and lokalekeuze's 6 (three are a validated per_page default, i.e. the lookup false positive) were measured on 2026-09-23 default branches. Re-measure on your own pin bump; code has moved since.How can I help you explore Laravel packages today?