Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Phpstan Warroom Rules Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

  1. Bump the pin: 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.
  2. Run the analyser (vendor/bin/phpstan analyse or your project's wrapper) and grep the two new identifiers:
    • forbidSentinelFallbackOnNarrowingHelper.sentinelFallback
    • forbidCredentialDerivedCacheKey.keyFromEncryptedAttribute
  3. First place to look: the rule configuration section of your project's phpstan.neon (or the shipped ruleset). The new optional parameter narrowingHelperNamespacePrefixes (listOf(string()), default ['App']) controls the entire first rule's scope.
  4. First use case: open the one boundary/import helper in your app that takes 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.

Implementation Patterns

Triage each sentinel hit into one of three remediations. The rule only accepts explicit handling:

  • Skip the write (no value → no persistence),
  • Fail closed (throw at the boundary),
  • Handle 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.

Gotchas and Tips

  • Expect new errors on already-clean code. This is a MAJOR-candidate rule by the project's own versioning note: code that was green on ^0.10 can red on ^0.11. Budget the drain into the bump PR, don't split it out.
  • Shape-keyed, not name-keyed. There is no helper-name list. Renaming your helper changes nothing; changing its signature (dropping the required mixed param, or making the return non-nullable) does. That's intentional — your helper inventory churns, the shape doesn't.
  • Required vs. optional 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.
  • Namespace matching is on boundaries. 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.
  • Union receivers fire if any branch qualifies. 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.
  • Deliberate misses — find these by hand. Non-literal fallbacks (?? $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.
  • Never flagged, don't bother checking: ?? 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.
  • Debugging a single hit: restrict analysis to the one file (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.
  • Known inventory is a heuristic, not a target. emmie's 8 hits (all in 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo