PHPStan Level 9: The Path to Bug-Free PHP

Three days. That’s how long I spent debugging a payment reconciliation bug that PHPStan would have caught in three seconds.

Or: How I Learned to Stop Worrying and Love the Type System

The bug was subtle. Our API wrapper returned null during maintenance windows, but only for certain transaction types. Our tests used mocks that never returned null. Code review focused on the calculation logic, not the null-safety. Production? Production crashed at 3 AM on a Sunday.

PHPStan Level 9 would have screamed at us: “Parameter #2 $bankData of function reconcileTransaction() expects BankData, BankData|null given.”

That 3 AM incident changed how I think about type safety. Today, I’m going to show you how we eliminated 90% of our type-related bugs without writing a single additional test. The secret? Your compiler is smarter than your tests.

Why Static Analysis Changes Everything

Remember the last time you saw this in production?

Fatal error: Uncaught Error: Call to a member function
format() on null in /var/www/app/PaymentProcessor.php:142

This happens because PHP is dynamically typed. Variables can be anything at runtime. Your IDE might warn you, your tests might catch 80% of cases, but that 20% finds its way to production at the worst possible time.

Static analysis tools like PHPStan analyze your code without running it. They build a complete type graph of your application and tell you exactly where things will break before you deploy.

The difference is profound:

Traditional Testing:

  • Write code
  • Write tests
  • Run tests
  • Deploy
  • Fix production bugs you didn’t test for

Static Analysis:

  • Write code
  • Run PHPStan
  • Fix type errors
  • Deploy with confidence
  • Sleep through the night

The PHPStan Level Journey (0-9)

PHPStan offers 10 levels of strictness, from “barely checking anything” to “your code is mathematically proven to not have type errors.” Each level catches progressively more subtle bugs.

Level 0: Basic Existence Checks

The gentlest introduction. PHPStan verifies that classes, functions, and methods you call actually exist.

// Level 0 catches this
$user = new UserrModel(); // Typo in class name
$user->getName(); // Class doesn't exist, method doesn't exist

// Level 0 allows this (but shouldn't)
function process($data) {
    return $data->value; // What if $data is null?
}

What it catches: Fatal typos, undefined classes, undefined methods What it misses: Everything related to types

Level 1-2: Unknown Classes and Methods

Checks that you’re calling methods that exist on the right objects.

// Level 2 catches this
$user = getUserById(123);
$user->nonExistentMethod(); // Method doesn't exist

// Level 2 allows this
function formatUser($user) {
    return $user->getName(); // What type is $user?
}

What it catches: Calling methods on wrong object types What it misses: Missing type hints, magic methods

Level 3-4: Basic Type Checking

Now we’re getting serious. PHPStan starts requiring basic type hints and checking them.

// Level 4 catches this
function processUsers(array $users): void {
    foreach ($users as $user) {
        echo $user->getName(); // $user could be anything!
    }
}

// Level 4 requires this
/**
 * @param User[] $users
 */
function processUsers(array $users): void {
    foreach ($users as $user) {
        echo $user->getName(); // Now PHPStan knows $user is User
    }
}

What it catches: Missing parameter types, missing return types What it misses: Nullable types, union types, complex generics

Level 5-6: Dead Code and Strict Comparisons

PHPStan starts checking your logic, not just your types.

// Level 5 catches this
function getUser(int $id): ?User {
    $user = $this->repository->find($id);

    if ($user === null) {
        return null;
    }

    return $user->getName(); // Wrong! Should return User, not string
}

// Level 6 catches this
$result = someFunction(); // Returns mixed
if ($result) { // Loose comparison
    // PHPStan: "This might not work how you think"
}

What it catches: Wrong return types, dead code paths, loose comparisons What it misses: Complex null safety scenarios

Level 7-8: Advanced Type Safety

This is where most production apps should aim. Almost everything is checked.

// Level 7 catches this
function processPayment(?PaymentData $data): void {
    // Accessing property on potentially null object
    $amount = $data->amount;
}

// Level 7 requires this
function processPayment(?PaymentData $data): void {
    if ($data === null) {
        throw new InvalidArgumentException('Payment data required');
    }

    $amount = $data->amount; // Safe!
}

// Level 8 catches this
$users = getUsersFromCache(); // Returns mixed
foreach ($users as $user) { // Can't iterate mixed
    // ...
}

What it catches: Most null pointer exceptions, type mismatches, unsafe mixed usage What it misses: The strictest mixed type rules

Level 9: Maximum Strictness

Welcome to the top. Level 9 is PHPStan’s strictest level (there’s also Level 10 in PHPStan v2, but that’s experimental).

At Level 9, PHPStan becomes almost obsessive about the mixed type:

“Be strict about the mixed type - the only allowed operation you can do with it is to pass it to another mixed.”

// Level 9 rejects this
function processData(mixed $data): void {
    if (is_array($data)) {
        count($data); // ERROR: Can't use mixed, even after is_array()
    }
}

// Level 9 requires this
/**
 * @param array<mixed> $data
 */
function processData(array $data): void {
    count($data); // OK: $data is array, not mixed
}

// Level 9 catches this subtle bug
function getValue(): mixed {
    return $this->cache->get('key');
}

function processValue(): void {
    $value = $this->getValue();
    // ERROR: Can't do ANYTHING with $value
    echo $value; // Not allowed
    $value->method(); // Not allowed
    $value + 5; // Not allowed
}

What it catches: Every possible type ambiguity Production reality: Level 8 is often more practical, Level 9 is for those who want absolute safety

Here’s what we found in production when we went from Level 6 to Level 9:

// Our API client (worked fine for 2 years)
class ApiClient {
    public function fetchTransaction(string $id) {
        $response = $this->http->get("/transactions/{$id}");
        return json_decode($response->getBody(), true);
        // Returns mixed! Could be null, array, or anything
    }
}

// Our reconciliation code
public function reconcile(string $transactionId): void {
    $data = $this->api->fetchTransaction($transactionId);

    // Level 9: "You can't access array keys on mixed"
    $amount = $data['amount'];

    // The fix
    /**
     * @return array{amount: float, currency: string}|null
     */
    public function fetchTransaction(string $id): ?array {
        $response = $this->http->get("/transactions/{$id}");
        $data = json_decode($response->getBody(), true);

        if (!is_array($data)) {
            return null;
        }

        return $data;
    }
}

That one change prevented the bug I mentioned at the start of this article.

The Baseline Strategy: Migrate Without Breaking Everything

Here’s the reality: you probably have a legacy codebase with thousands of PHPStan errors. You can’t fix them all before shipping your next feature.

The baseline feature is your escape hatch.

What is a Baseline?

A baseline is a snapshot of all current errors. PHPStan will ignore these errors in future runs, but it will scream at you about new errors.

This lets you:

  1. Set a high strictness level (like Level 8)
  2. Ignore all existing errors
  3. Force all new code to meet that standard
  4. Fix old errors incrementally

Generating Your First Baseline

# Generate baseline at Level 8
vendor/bin/phpstan analyse \
    --level 8 \
    --configuration phpstan.neon \
    src/ tests/ \
    --generate-baseline

This creates phpstan-baseline.neon:

parameters:
  ignoreErrors:
    - message:
        "#^Method App\\\\PaymentProcessor::process\\(\\) has parameter \\$data
        with no type specified\\.$#"
      count: 1
      path: src/PaymentProcessor.php
    - message: "#^Property App\\\\User::\\$metadata has no type specified\\.$#"
      count: 15
      path: src/Models/User.php
    - message:
        "#^Method App\\\\ApiClient::fetchTransaction\\(\\) has no return type
        specified\\.$#"
      count: 1
      path: src/ApiClient.php

Include the Baseline

Update phpstan.neon:

includes:
  - phpstan-baseline.neon

parameters:
  level: 8
  paths:
    - src
    - tests

Now run PHPStan:

vendor/bin/phpstan analyse

Output:

 [OK] No errors

Beautiful. Now write some new code with a type error:

// New feature added today
class InvoiceGenerator {
    public function generate($invoice) { // Missing type hint
        return $invoice->format();
    }
}

Run PHPStan again:

vendor/bin/phpstan analyse

Output:

 ------ ---------------------------------------------------------------
  Line   src/InvoiceGenerator.php
 ------ ---------------------------------------------------------------
  3      Method App\InvoiceGenerator::generate() has parameter
         $invoice with no type specified.
 ------ ---------------------------------------------------------------

 [ERROR] Found 1 error

This is the power of baselines. Old code? Ignored. New code? Must be perfect.

Incremental Improvement Strategy

Here’s how we migrated our 150,000 line codebase to Level 9 over 4 months:

Week 1-2: Generate Baseline at Level 6

vendor/bin/phpstan analyse --level 6 --generate-baseline
git add phpstan-baseline.neon
git commit -m "chore: add PHPStan baseline at level 6"

Week 3-8: Fix One Module Per Sprint

# Pick a small module
vendor/bin/phpstan analyse src/Billing/ --level 6

# Fix all errors in that module
# Then remove those errors from baseline
vendor/bin/phpstan analyse --generate-baseline

Week 9-12: Increase to Level 7

# Generate new baseline at Level 7
vendor/bin/phpstan analyse --level 7 --generate-baseline

# Continue fixing module by module

Week 13-16: Level 8 (Production Goal)

Month 4: Level 9 for Critical Paths

We didn’t go Level 9 everywhere. We used directory-specific configs:

# phpstan.neon
parameters:
    level: 8
    paths:
        - src
        - tests

# phpstan-strict.neon (for critical code)
includes:
    - phpstan.neon

parameters:
    level: 9
    paths:
        - src/Billing
        - src/PaymentProcessing
        - src/Security

Now we run two checks:

# Regular check (Level 8)
vendor/bin/phpstan analyse

# Strict check for critical code (Level 9)
vendor/bin/phpstan analyse -c phpstan-strict.neon

Custom Rules: Enforce Your Team’s Conventions

PHPStan’s built-in rules are excellent, but every team has unique conventions. Custom rules let you enforce them automatically.

Example: Factory Pattern Enforcement

Let’s say your team uses factories for domain models, and you want to prevent direct instantiation:

// Bad: Direct instantiation
$user = new User($name, $email);

// Good: Use factory
$user = UserFactory::create($name, $email);

Create a custom rule:

<?php declare(strict_types = 1);

namespace App\PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<New_>
 */
class NoDirectDomainModelInstantiationRule implements Rule
{
    public function getNodeType(): string
    {
        return New_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        if (!$node->class instanceof Node\Name) {
            return [];
        }

        $className = $node->class->toString();

        // Check if this is a domain model
        if (!str_starts_with($className, 'App\\Domain\\')) {
            return [];
        }

        // Allow instantiation in factories and tests
        $currentFile = $scope->getFile();
        if (str_contains($currentFile, 'Factory') ||
            str_contains($currentFile, 'Test')) {
            return [];
        }

        return [
            RuleErrorBuilder::message(
                sprintf(
                    'Domain model %s should not be instantiated directly. Use %sFactory instead.',
                    $className,
                    $className
                )
            )
            ->identifier('app.domainModel.directInstantiation')
            ->build(),
        ];
    }
}

Register it in phpstan.neon:

rules:
  - App\PHPStan\Rules\NoDirectDomainModelInstantiationRule

Now PHPStan enforces your architecture:

// This triggers our custom rule
$user = new User('John', 'john@example.com');

// Error: Domain model App\Domain\User should not be
// instantiated directly. Use App\Domain\UserFactory instead.

More Practical Custom Rules

Prevent Slow Functions in Hot Paths:

/**
 * @implements Rule<FuncCall>
 */
class NoSlowFunctionsInHotPathRule implements Rule
{
    private const SLOW_FUNCTIONS = [
        'sleep',
        'usleep',
        'file_get_contents', // Without caching
    ];

    public function processNode(Node $node, Scope $scope): array
    {
        $functionName = $node->name->toString();
        $currentFile = $scope->getFile();

        // Check if we're in a hot path
        if (!str_contains($currentFile, '/HotPath/')) {
            return [];
        }

        if (in_array($functionName, self::SLOW_FUNCTIONS, true)) {
            return [
                RuleErrorBuilder::message(
                    sprintf('Slow function %s() should not be used in hot paths', $functionName)
                )->build(),
            ];
        }

        return [];
    }
}

Require Specific PHPDoc for Public APIs:

/**
 * @implements Rule<ClassMethod>
 */
class PublicApiMustHaveExamplesRule implements Rule
{
    public function processNode(Node $node, Scope $scope): array
    {
        // Only check public methods in src/Api
        if (!$node->isPublic()) {
            return [];
        }

        $currentFile = $scope->getFile();
        if (!str_contains($currentFile, '/Api/')) {
            return [];
        }

        $docComment = $node->getDocComment();
        if ($docComment === null || !str_contains($docComment->getText(), '@example')) {
            return [
                RuleErrorBuilder::message(
                    'Public API methods must include @example in PHPDoc'
                )->build(),
            ];
        }

        return [];
    }
}

CI/CD Integration: Make It Automatic

Static analysis is only useful if it runs automatically. Here’s how to integrate PHPStan into your pipeline.

GitHub Actions

# .github/workflows/phpstan.yml
name: PHPStan

on: [push, pull_request]

jobs:
  phpstan:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          coverage: none

      - name: Install dependencies
        run: composer install --prefer-dist --no-progress

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --error-format=github

      - name: Run PHPStan (Strict) on Critical Code
        run:
          vendor/bin/phpstan analyse -c phpstan-strict.neon
          --error-format=github

GitLab CI

# .gitlab-ci.yml
phpstan:
  image: php:8.3-cli
  stage: test
  before_script:
    - apt-get update && apt-get install -y git unzip
    - curl -sS https://getcomposer.org/installer | php
    - php composer.phar install
  script:
    - vendor/bin/phpstan analyse --error-format=gitlab
  artifacts:
    reports:
      codequality: phpstan-report.json

Pre-commit Hook (Local Development)

#!/bin/bash
# .git/hooks/pre-commit

echo "Running PHPStan..."

# Get list of PHP files in staging
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')

if [ -z "$FILES" ]; then
    exit 0
fi

# Run PHPStan only on changed files
vendor/bin/phpstan analyse $FILES --level=8

if [ $? -ne 0 ]; then
    echo ""
    echo "PHPStan found errors. Commit aborted."
    echo "Fix the errors or use 'git commit --no-verify' to skip this check."
    exit 1
fi

exit 0

Make it executable:

chmod +x .git/hooks/pre-commit

Progressive CI Strategy

We use a tiered approach:

# .github/workflows/quality.yml
name: Code Quality

on: [push, pull_request]

jobs:
  phpstan-baseline:
    name: PHPStan (Baseline)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
      - run: composer install
      - run: vendor/bin/phpstan analyse
    # This must pass (uses baseline)

  phpstan-strict:
    name: PHPStan (No Baseline)
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
      - run: composer install
      - run: vendor/bin/phpstan analyse --level 9
    # This can fail (shows progress toward Level 9)

The first job blocks merging if new code has errors. The second job shows how many baseline errors remain, but doesn’t block.

PHPStan vs Psalm: Which Should You Choose?

Both are excellent. Here’s my take after using both in production:

PHPStan Strengths

Larger ecosystem - More extensions (Doctrine, Symfony, Laravel) ✅ Better Laravel support - Larastan is built-in to Laravel 10+ ✅ Simpler configuration - NEON format is easier than Psalm’s XML ✅ More forgiving learning curve - Easier to adopt incrementally ✅ Better error messages - Usually clearer about what’s wrong

Psalm Strengths

Taint analysis - Catches security issues (SQL injection, XSS) ✅ More advanced type inference - Sometimes smarter about generics ✅ Immutability checking - Built-in support for @psalm-immutable ✅ Language server - Better IDE integration out of the box

My Recommendation

Use PHPStan if:

  • You’re using Laravel, Symfony, or Doctrine
  • You want the easiest onboarding for your team
  • You need extensive framework-specific extensions
  • You prefer YAML/NEON over XML

Use Psalm if:

  • Security is paramount (taint analysis is gold)
  • You have complex generic types
  • You want the strictest possible checking
  • You prefer XML configuration

Use both if:

  • You can afford the CI time
  • You want maximum safety
  • You have critical security requirements

We use PHPStan at Level 8 with strict rules for most code, and we run Psalm with taint analysis only on security-sensitive modules (authentication, payment processing, admin panels).

# .github/workflows/security.yml
security-analysis:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: shivammathur/setup-php@v2
      with:
        php-version: "8.3"
    - run: composer require --dev vimeo/psalm
    - run: vendor/bin/psalm --taint-analysis src/Security/

Real Production Impact: The Numbers

Here’s what happened when we introduced PHPStan Level 8 to our payment processing system:

Before PHPStan:

  • Production errors: 15-20 per week
  • Type-related bugs: ~60% of all bugs
  • Average time to fix: 45 minutes
  • P1 incidents: 1-2 per month

After PHPStan (6 months):

  • Production errors: 2-3 per week (85% reduction)
  • Type-related bugs: ~10% of all bugs (83% reduction)
  • Average time to fix: 15 minutes (caught earlier)
  • P1 incidents: 1 in 6 months

Developer Experience:

  • Initial setup time: 3 days
  • Baseline cleanup: 4 months (incremental)
  • Daily overhead: ~2 minutes per PR (automated)
  • Bugs prevented: Countless (literally, they never happened)

The ROI is undeniable. The 3 AM pages stopped. Code reviews became faster because we stopped discussing “what if this is null?” The confidence to refactor improved dramatically.

Your Migration Checklist

Ready to start your PHPStan journey? Here’s your roadmap:

Week 1: Setup

  • [ ] Install PHPStan: composer require --dev phpstan/phpstan
  • [ ] Create basic config: phpstan.neon
  • [ ] Run Level 0: vendor/bin/phpstan analyse --level 0 src/
  • [ ] Fix all Level 0 errors (should be easy)

Week 2: Establish Baseline

  • [ ] Choose target level (recommend Level 6 or 7)
  • [ ] Generate baseline: vendor/bin/phpstan analyse --level 7 --generate-baseline
  • [ ] Add baseline to config
  • [ ] Integrate into CI

Month 1: Enforce on New Code

  • [ ] All PRs must pass PHPStan
  • [ ] No new baseline entries allowed
  • [ ] Fix errors in files you touch

Month 2-3: Incremental Cleanup

  • [ ] Pick 1-2 modules per sprint
  • [ ] Remove from baseline
  • [ ] Update baseline

Month 4+: Increase Strictness

  • [ ] Regenerate baseline at Level 8
  • [ ] Add strict rules extension
  • [ ] Consider Level 9 for critical code

Bonus: Advanced

  • [ ] Write custom rules for team conventions
  • [ ] Split strict config for critical paths
  • [ ] Add Psalm for security analysis
  • [ ] Enable PHPStan Pro for better errors

The Bottom Line

Three days of debugging versus three seconds of static analysis. That’s the choice.

PHPStan Level 9 represents the pinnacle of type safety in PHP. It’s not always practical for every line of code, but the journey from Level 0 to Level 8+ will transform your codebase. You’ll catch bugs before they’re written. You’ll refactor with confidence. You’ll sleep through those Sunday nights.

The baseline strategy makes migration painless. Custom rules enforce your conventions automatically. CI integration makes it all automatic.

Start today. Run composer require --dev phpstan/phpstan. Generate that baseline. Watch your production errors drop.

Your future self—the one not debugging null pointer exceptions at 3 AM—will thank you.

Resources


What’s your horror story with type-related bugs? Have you tried PHPStan or Psalm? What level are you running in production? I’d love to hear your migration strategies in the comments.