phpstan/phpstan-strict-rules
Extra strict, opinionated PHPStan rules for defensive, strongly typed PHP: require booleans in conditions, strict function-call flags, numeric arithmetic operands, matching switch types, no useless casts, and safer loop variable handling.
composer require --dev phpstan/phpstan:^2.3 phpstan/phpstan-strict-rules:^2.1
# Larastan 3.x is the PHPStan 2-compatible line
composer require --dev larastan/larastan:^3
Version pairing is the first thing to check: strict-rules 2.1.0 requires PHPStan ≥ 2.3.0, and the 2.x line is built against PHPStan 2.x only. If you are still on PHPStan 1.x / Larastan 2.x, stay on strict-rules 1.x — a floating ^2.1 constraint on an old PHPStan install is an immediate composer conflict, and mixing 1.x PHPStan with 2.x rules silently changes what fires.
If phpstan/extension-installer is installed, the rules are auto-registered. Otherwise add the include explicitly:
# phpstan.neon
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
Minimal first run:
./vendor/bin/phpstan analyse app --level=6 --memory-limit=2G
Where to look first: not the README — vendor/phpstan/phpstan-strict-rules/rules.neon. That file is the source of truth for two things: the strictRules.* parameter names (the README table drifts by a rule or two), and, more importantly, the default PHPStan parameters this package silently changes. It ships opinionated defaults for checkAlwaysTrueInstanceof, checkAlwaysTrueCheckTypeFunctionCall, checkAlwaysTrueStrictComparison, and it interacts with treatPhpDocTypesAsCertain. You inherit behaviour changes you never configured, which is the usual "why did PHPStan start failing after a composer update" cause. The PHPStan side of those parameters is documented at phpstan.org/config-reference#stricter-analysis.
First use case: don't turn it all on. In a real Laravel codebase allRules: true on day one produces hundreds of errors from if ($user), array_filter($data), and $rows[] = $row. Start with the two rules that find actual bugs and are cheap to fix:
parameters:
strictRules:
allRules: false
disallowedLooseComparison: true # == and !=
strictFunctionCalls: true # in_array(..., true)
Fix those in one PR, then grow. That first pass in a typical app usually surfaces a handful of genuine '0.0' == 0-style comparison bugs plus every in_array($request->input('role'), $roles) missing its third argument.
Phased adoption with a shrinking baseline. Run with allRules: true, generate a baseline, and commit it:
./vendor/bin/phpstan analyse app --generate-baseline
Then treat the baseline file as a burn-down list. Every new violation fails CI immediately, and the baseline only shrinks. This is by far the most practical path for an existing Laravel app — it gets you full coverage across app/ from day one without a 400-error PR. Error identifiers are stable in PHPStan 2.x (disallowedLooseComparison, booleansInConditions, strictArrayFilter), so the baseline survives package upgrades far better than line-based ignores did. When you move 1.x → 2.x, regenerate the baseline once and review the diff — that diff is your real upgrade changelog.
Layer the strictness. Keep Laravel's framework-shaped directories out of the strict config and analyse them with core PHPStan only. Migrations and config files are full of permissive idioms that the strict rules hate and that are not worth your time:
# phpstan.neon — strict everywhere
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
parameters:
paths:
- app
- src
excludePaths:
- app/Console/Kernel.php
For the awkward corners (legacy service classes, third-party adapters), use per-file disables rather than global ones. In PHPStan 2 an ignore must name an identifier — bare @phpstan-ignore is itself an error:
// @phpstan-ignore disallowedLooseComparison
if ($payload['type'] == 'refund') { ... }
Inline @phpstan-ignore with an identifier is self-documenting, greppable, and scoped — a much better tool than strictRules.disallowedLooseComparison: false in the config, which turns the whole rule off repo-wide.
Let the formatter fix what it can, let PHPStan guard the rest. Slevomat's coding standard (SlevomatCodingStandard.ControlStructures.RequireNullCoalesceOperator, RequireStrictComparison, RequireExplicitBooleanReturnTypeHint) auto-fixes the mechanical 80% — short ternaries, loose comparisons, empty(). Run it once, then use phpstan-strict-rules as the invariant that stops it regressing. The two tools target the same idioms from opposite directions and pair well: one rewrites, one refuses.
Narrow at the boundary so the rules pass naturally. Most booleansInConditions errors in Laravel come from untyped boundaries: config(), $request->input(), Eloquent magic attributes. Fix them at the source rather than sprinkling (bool) casts (which will trip uselessCast later once your types clean up):
// instead of: if (config('features.billing')) { ... }
if (config('features.billing') === true) { ... }
// instead of: if ($request->input('force')) { ... }
if ($request->boolean('force')) { ... }
// instead of: if (count($roles)) { ... }
if ($roles->isNotEmpty()) { ... }
$request->boolean() and the Collection predicates exist precisely for this; they're the idiomatic Laravel answer to the rule rather than a workaround for it.
Make it a CI gate, not a pre-commit nag. PHPStan on a mid-size Laravel app takes far longer than a pre-commit hook should. Put it in CI with GitHub's native error format, and run it in parallel:
./vendor/bin/phpstan analyse --memory-limit=2G --error-format=github
parameters:
parallel:
maximumNumberOfProcesses: 4
strictFunctionCalls understands named arguments, so in_array(needle: $x, haystack: $list, strict: true) is recognised, and array_keys(..., strict: true) only requires the third argument when you're searching by value — plain array_keys($array) stays legal.
booleansInConditions is the noisiest rule by far, and Laravel idiom is the reason. if ($user), if (count($x)), if ($request->input('x')), while ($row = ...) all fail. The separate booleansInLoopConditions toggle exists so you can accept the while (($row = $stmt->fetch()) !== false) churn independently of the if churn — they're different conversations. Enable them separately.
Loop-variable-overwrite reporting got quieter in 2.1.0. The rule now only reports overwrites of a loop variable that is still used after the loop, instead of flagging every shadowed name. If you had excluded files or disabled the rule because of false positives on reused $item/$row variables, re-enable it on 2.1.0 and re-measure before keeping the suppression.
treatPhpDocTypesAsCertain: false changes results. If you've disabled that (common on legacy Laravel apps with optimistic docblocks), some strict rules lose type certainty and stop reporting — which means your error count can drop after enabling the setting. If a rule "isn't firing" on a variable that's clearly an int per docblock, that's usually why. Check rules.neon and the release notes before assuming a bug.
disallowedEmpty collides with blank()/filled(). Laravel's helpers are fine and do not trip the rule — empty() itself is the target. The migration is filled($value) instead of !empty($value) and blank($value) instead of empty($value), which is both stricter and reads better.
disallowedImplicitArrayCreation only fires when the variable doesn't exist yet. This is the rule most people misread:
$rows = []; // fine
foreach ($items as $item) {
$rows[] = $item->id; // fine — $rows exists
}
foreach ($items as $item) {
$rows[] = $item->id; // flagged — $rows never declared
}
So the fix is a one-line $rows = []; and it's always a genuine smell (usually a leftover from a refactor or a compact()-style pattern). Cheap and worth having.
strictArrayFilter will hit you in old code, not new. array_filter($data) with no callback relies on truthiness — which is exactly what the rest of this package forbids, so the rule is consistent. Replace with array_filter($data, fn ($v) => $v !== null) or, if you wanted "remove falsy", ->filter() on a Collection with an explicit predicate. The rule is deliberately loosened for unions of clearly truthy/falsey types, so not every callback-less call is flagged — don't assume an inconsistent result is a bug.
requireParentConstructorCall bites custom Eloquent models and exceptions. Any __construct in a class extending Model, a Command, a Mailable, or Exception must call parent::__construct(...). In Eloquent this is almost always a design smell (use booted() / boot() or casts() instead of a constructor), so treat the error as a nudge to move the logic rather than to add the parent call.
dynamicCallOnStaticMethod looks scary for Eloquent but rarely fires. User::where(...) goes through __callStatic, not a statically declared method, so it is not flagged. The rule targets genuinely static methods invoked on an instance — $builder->foo() where foo is static. If you see it, it's real.
uselessCast and type-narrowing fight each other — and 2.1.0 made the messages better. You cast (int) $model->id to satisfy numericOperandsInArithmeticOperators; later generics or a better docblock make it an int, and now uselessCast flags it. As of 2.1.0 the arithmetic operand rules name the node types they want in the error message, so you can tell which side of the expression is untyped instead of guessing. Solve the ping-pong by pushing the type up to the source (Eloquent cast definitions, docblock generics, @property annotations) rather than by toggling rules. If you can't, use a local @phpstan-ignore uselessCast.
checkAlwaysTrue* produces a lot of noise in tests and factories. assertInstanceOf followed by an instanceof, or a mock typed as the class, trips checkAlwaysTrueInstanceof. You can turn exactly these three off without abandoning the package:
parameters:
checkAlwaysTrueInstanceof: false
checkAlwaysTrueCheckTypeFunctionCall: false
checkAlwaysTrueStrictComparison: false
Pin the version or opt in rule-by-rule. The README notes "additional rules are coming in subsequent releases" — a floating constraint will one day add a rule or bump the PHPStan floor (2.1.0 required PHPStan 2.3.0) and break CI with no code change. Either pin the minor, or run allRules: false and enable rules explicitly, which makes upgrades a deliberate choice. Remember allRules: false does not fully neutralise the package: the core-default overrides in rules.neon still apply. That surprised many teams — read that file before assuming allRules: false means "inert".
Blade and .blade.php files aren't analysed at all. None of these rules touch your templates, so the ?:, loose comparisons, and truthiness in Blade are entirely outside this package's reach. If that's a gap you care about, it's a separate problem (linting compiled views or moving logic into typed view models) — don't expect strict-rules to catch it.
How can I help you explore Laravel packages today?