- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A team I worked with last year decided to go straight to PHPStan level 10 on a Laravel 11 app that had never run static analysis. They generated a baseline with 4,200 errors. Six weeks later the baseline had grown, not shrunk, because every new file added more errors than anyone had time to fix. They eventually dropped back to level 6 and quietly stopped talking about static analysis.
That's the level-10 trap. The higher levels are tempting because the number is bigger. But the marginal bugs they catch don't justify the friction they add. Level 8 is where the curve flattens. Past it, you're paying with team patience for diminishing returns.
What each PHPStan level actually adds
PHPStan ships with eleven levels, 0 through 10. Each one turns on a specific set of checks. From the official docs:
- 0: basic syntax and unknown classes.
- 1: undefined variables, unknown magic methods.
-
2: unknown methods checked on all expressions, not just
$this. - 3: return types, types assigned to properties.
- 4: basic dead code (always-false conditions, unreachable statements).
- 5: check argument types passed to methods.
- 6: report missing typehints (parameters, return types, properties).
- 7: partially wrong union types. Checks when only some types in a union have the method.
- 8: calling methods and accessing properties on nullable types.
-
9: be strict about the
mixedtype. The only allowed operation onmixedis passing it to anothermixed. -
10: check for code unreachable due to type narrowing; reject calls on
never.
The level number isn't a percentage of safety. It's a ladder of strictness, and each rung has a different slope.
The level-8 sweet spot
Level 8 is where most of the value lives. Specifically, the null-on-nullable check. This one rule catches the single most common class of production bugs in PHP: calling a method on something that came back as null.
final class OrderService
{
public function __construct(
private readonly OrderRepository $orders,
) {}
public function ship(int $orderId): void
{
$order = $this->orders->find($orderId);
// level 8 flags this. find() returns ?Order.
$order->markShipped();
}
}
PHPStan output at level 8:
------ ------------------------------------------------------------
Line src/OrderService.php
------ ------------------------------------------------------------
14 Cannot call method markShipped() on App\Order|null.
------ ------------------------------------------------------------
That's not a theoretical bug. That's the bug that wakes someone up at 3am because the order ID got typo'd in a webhook payload and find() returned null. Level 8 catches it before it ships.
Combined with level 6's "missing typehints" rule, you've eliminated about 95% of the type-related runtime errors a PHP codebase tends to produce. The remaining 5% are exotic. That's where levels 9 and 10 live.
What levels 9 and 10 actually add
Level 9 is the war on mixed. Once enabled, you can't do anything with a mixed value except pass it to another mixed slot.
function processWebhook(array $payload): void
{
// level 9: error. $payload['amount'] is mixed.
$amount = $payload['amount'] + 100;
}
To fix it, you have to narrow:
function processWebhook(array $payload): void
{
$rawAmount = $payload['amount'] ?? null;
if (!is_int($rawAmount)) {
throw new InvalidArgumentException('amount must be int');
}
$amount = $rawAmount + 100;
}
That's good code. But on a 200,000-line codebase that talks to dozens of third-party APIs, you'll find thousands of these. Every JSON decode, every $_POST, every cache hit, every database row that came back as an array. Each one needs narrowing. Most are fine in practice because the calling code already validates upstream. PHPStan doesn't know that.
Level 10 adds two more rules. The visible one is "no calls on never". The expensive one is stricter array shape inference. PHPStan refuses to assume keys exist in mixed[] unless you've proven it.
Here's a level-10 false positive that wastes an afternoon:
/** @param array<string, mixed> $config */
public function connect(array $config): PDO
{
$host = $config['host']; // level 10: mixed
$user = $config['user']; // level 10: mixed
$pass = $config['pass']; // level 10: mixed
return new PDO("mysql:host={$host}", $user, $pass);
}
The fix involves a value-of-shaped annotation or full narrowing per key. For a $config array that's literally validated by the framework's config loader two lines earlier. That's the friction.
The friction cost is real
Three things make level 10 expensive even when your own code is clean:
-
Third-party stubs. Half the Laravel and Symfony ecosystem still leaks
mixedthrough facade returns, container resolves, and event dispatcher results. You'll spend more time tagging these with@phpstan-ignore-next-linethan fixing real bugs. -
Generic narrowing. PHPStan's generic inference is good, not perfect. Collection-style operations (
map,filter,reduce) sometimes return broader types than you'd write by hand. At level 10, you have to spell every assertion out. -
Onboarding cost. A new developer joins. They write idiomatic Laravel. PHPStan rejects six of their first ten files. They learn to write
// @phpstan-ignore-next-linereflexively, which defeats the entire point.
Level 8 catches the bugs that hurt. Level 10 catches the bugs that almost never happen, and trains your team to silence the linter.
When level 10 is the right call
There are exactly three cases:
-
Library code published on Packagist. Your
mixed-narrowing burden is paid once. Downstream users get clean types. Worth it. - Public APIs that accept untyped input (webhook receivers, GraphQL endpoints). Level 10 forces you to validate at the boundary, which is what you'd do anyway.
- Audit-grade or regulatory tooling. Payments, healthcare, anything that can't have a single null-pointer pop in prod. The friction is part of the contract.
For application code in a normal Laravel or Symfony project? Level 8. Always 8.
A real-world delta
A team I talked to ran an experiment on their Laravel 11 SaaS: they ran level 8 and level 10 in parallel for one quarter, then audited every prod bug.
- Level 8 caught 47 bugs before they shipped.
- Level 10 caught the same 47, plus 3 more. All in third-party integration code where they'd already added input validation that PHPStan couldn't see.
- The level-10 baseline contained 1,840 errors they hadn't fixed. The level-8 baseline contained 140.
Three extra bugs caught, against 1,700 extra baseline entries the team had to triage. Net: not worth it.
How to migrate to level 8 from level 5
This is the part most teams get wrong. They jump three levels, generate a 5,000-entry baseline, then never look at it again.
Do it one level at a time. Here's a real phpstan.neon for a Laravel 11 app:
includes:
- vendor/larastan/larastan/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- app
- config
- database/factories
- database/seeders
excludePaths:
- app/Console/Kernel.php
- app/Http/Kernel.php
checkMissingIterableValueType: true
checkGenericClassInNonGenericObjectType: true
reportUnmatchedIgnoredErrors: true
tmpDir: .phpstan-cache
For Symfony 7, swap the includes:
includes:
- vendor/phpstan/phpstan-symfony/extension.neon
- vendor/phpstan/phpstan-doctrine/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- phpstan-baseline.neon
parameters:
level: 6
paths:
- src
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
Now the workflow:
# step 1: at current level, get to zero new errors.
vendor/bin/phpstan analyse
# step 2: generate a baseline so existing errors don't block CI.
vendor/bin/phpstan analyse --generate-baseline
# step 3: commit phpstan-baseline.neon. CI now stays green.
git add phpstan-baseline.neon phpstan.neon
git commit -m "phpstan: baseline at level 6"
Then bump the level by 1, repeat. The important part: track baseline size across commits. A team I work with has this in CI:
#!/bin/bash
# scripts/phpstan-baseline-budget.sh
ERRORS=$(vendor/bin/phpstan analyse --error-format=json \
| jq '.totals.errors')
if [ "$ERRORS" -gt "0" ]; then
echo "PHPStan found $ERRORS new errors not in baseline"
exit 1
fi
BASELINE_COUNT=$(grep -c "message:" phpstan-baseline.neon || echo 0)
echo "Current baseline size: $BASELINE_COUNT"
# fail the build if the baseline grew
LAST_COUNT=$(git show HEAD~1:phpstan-baseline.neon 2>/dev/null \
| grep -c "message:" || echo 0)
if [ "$BASELINE_COUNT" -gt "$LAST_COUNT" ]; then
echo "Baseline grew from $LAST_COUNT to $BASELINE_COUNT. Fix it."
exit 1
fi
The point: the baseline is a debt ledger. It's allowed to shrink. It's not allowed to grow. New code must be clean at the current level.
The gotcha nobody documents
reportUnmatchedIgnoredErrors: true is the setting that saves you in two years. Without it, your @phpstan-ignore-next-line comments rot silently. The code they were ignoring gets refactored, the ignore stays, and now it's papering over a different error.
With that flag on, PHPStan tells you:
------ ----------------------------------------------------------------
Line src/Payment/StripeAdapter.php
------ ----------------------------------------------------------------
87 Ignored error pattern #Call to undefined method# was not
matched in reported errors.
------ ----------------------------------------------------------------
Now you know to delete the stale ignore. Without it, you accumulate phantom suppressions for years.
Run it like this in CI
vendor/bin/phpstan analyse --memory-limit=1G --no-progress
Add --xdebug only when actively debugging the analyzer itself. It makes runs 5x slower. For Laravel projects, pair it with larastan (which handles facades, query builders, Eloquent generics). For Symfony, phpstan-symfony knows about container service IDs. Both include phpstan-strict-rules for the "no ==, no missing returns" hygiene checks that level 5 doesn't cover.
The actual call
Migrate to level 8. Stop there. Spend the friction budget you saved on writing tests for the integration boundaries level 10 was trying to protect. That's where the bugs really live, and tests catch them better than the type system ever will.
Level 10 isn't wrong. It's right for the wrong codebases. Most production PHP isn't library code.
What level is your team on right now, and what's stopping you from pushing one rung higher?
If this was useful
Static analysis catches the bugs that hurt. Architecture decides which bugs are even possible. Decoupled PHP is the architectural layer your codebase reaches for after the framework defaults stop protecting you. Boundaries, ports, and adapters that make the difference between a codebase you maintain and one that maintains itself.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)