DEV Community

Cover image for PHPStan Level 10: Why You Should Stop at 8 (and What 10 Actually Costs You)
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHPStan Level 10: Why You Should Stop at 8 (and What 10 Actually Costs You)


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 mixed type. The only allowed operation on mixed is passing it to another mixed.
  • 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

PHPStan output at level 8:

 ------ ------------------------------------------------------------
  Line   src/OrderService.php
 ------ ------------------------------------------------------------
  14     Cannot call method markShipped() on App\Order|null.
 ------ ------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Third-party stubs. Half the Laravel and Symfony ecosystem still leaks mixed through facade returns, container resolves, and event dispatcher results. You'll spend more time tagging these with @phpstan-ignore-next-line than fixing real bugs.
  2. 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.
  3. 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-line reflexively, 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:

  1. Library code published on Packagist. Your mixed-narrowing burden is paid once. Downstream users get clean types. Worth it.
  2. 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.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
 ------ ----------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)