esfahanahan/phpstan-rules
A collection of custom PHPStan rules for Laravel and PHP projects, helping teams catch architectural and coding issues early. Drop it into your phpstan.neon config to enforce stricter static analysis and keep code quality consistent across the codebase.
composer require --dev hosni/<package>), then publish the config if the package ships one (php artisan vendor:publish --tag=<package>-config). The detector is a dev-only dependency — keep it out of production autoload.php artisan <package>:scan or a listener that runs during APP_DEBUG). Running it once against an existing app is the fastest way to see how noisy it is on your codebase before wiring it into anything.load() calls that reload a parent relation which was already constrained (e.g. you did $post->comments()->where('approved', true)->get() earlier, then later call $post->load('comments'), silently throwing away the constraint).load()-reload finding is usually the highest-signal one because it changes query results, not just query count — it is a correctness bug, not just a performance smell.Local feedback loop. Run the scanner as part of your normal test suite rather than as an ad-hoc command. If the package exposes a middleware or event listener, enable it in phpunit.xml (APP_DEBUG=true, plus whatever env flag the package reads) so every feature test doubles as a detection pass. This is where the new v1.1.0 rule pays off: integration tests that render a page after pre-constraining a relation will surface the reload immediately.
Route-scoped scanning. Rather than scanning the whole app and drowning in legacy findings, drive the scanner from a small set of "canary" routes — the ones with the most relationships. Add routes as you clean them up.
CI gate with a baseline file. Adopt whatever ignore/baseline mechanism the package offers (config array of ignored call sites, or an inline suppression comment). Generate the baseline on the commit where you upgrade to v1.1.0 so the new load() findings for new code fail the build, while existing ones are triaged separately. Never regenerate the baseline silently in CI — diff it in review.
Pair with eager-loading conventions. The load()-reload rule complements, not replaces, standard N+1 detection. A useful workflow:
$post->load('comments') with $post->loadMissing('comments'), or move the constraint into the eager load ($post->load(['comments' => fn ($q) => $q->where('approved', true)])).Assert on results, not just counts. For the new rule specifically, a query-count assertion (DB::listen + counter) will not catch it — the bug is a wrong result set with a similar query count. Pair the detector with a feature test that asserts on the collection contents, otherwise you can "fix" the finding by removing the constraint and stay green.
Per-model opt-out for deliberate reloads. Keep a short, commented config block listing the handful of places where reloading a constrained relation is intentional (e.g. swapping a filtered view for the full set on the same request). Anything not on that list should be treated as a defect.
loadMissing() is not a drop-in fix for every hit. It suppresses the reload when the relation is already loaded — including when it was loaded with a constraint you now want gone. If you genuinely need the unconstrained set, you must unsetRelation('comments') (or use load() deliberately) and should suppress the finding with a comment.load(['comments' => fn ($q) => ...]) counts as constrained, so the rule generally won't fire — but the constraint has to actually be on the relation being loaded. Watch for constraints applied via a scoped relation method ($post->approvedComments()) where the name differs from the relation being load()ed; the detector may not correlate them, so the reload goes unreported. Verify manually in that pattern.$post->load('comments.author') reloads comments too. The finding is usually reported against the outer segment; don't "fix" only the nested part.DB::listen() around the flagged line. The detector reports the call site; the SQL tells you which constraint was dropped, which is often the fastest way to confirm whether it's a real bug or a false positive worth suppressing.Rule interface), wrap your own checks for house conventions — e.g. "no load() inside a loop" — in the same pipeline so all query-shape findings land in one report and one CI gate.load().How can I help you explore Laravel packages today?