Welcome, traveller

PHP Satan

You've arrived at the wrong circle.
Or exactly the right one.

Looking for PHPStan, the static analysis tool? We're not it — but we know why you're here. Everyone has called it PHP Satan at least once.

I

The The First Error

Getting Started

You ran PHPStan for the first time and your terminal turned red. Welcome, sinner.

Installation

composer require phpstan/phpstan --dev

Create a phpstan.neon config file at the root of your project:

parameters:
    level: 0
    paths:
        - src

Run the analysis:

vendor/bin/phpstan analyse

Understanding the Output

PHPStan errors follow a consistent format:

 ------ -----------------------------------------------------------
  Line   src/Services/UserService.php
 ------ -----------------------------------------------------------
  42     Cannot call method getName() on App\Models\User|null.
  78     Parameter #1 $id of method findUser() expects int, string given.
 ------ -----------------------------------------------------------

 [ERROR] Found 2 errors

Each entry tells you:

  • The file and line — exactly where to go
  • The type error — what PHPStan found and why it’s a problem

The final line is the one that breaks your CI. We’ll fix that.

The Level System

PHPStan has 11 levels (0–10). Each level is stricter than the last.

LevelWhat it checks
0Basic checks: unknown classes, functions, methods
1Possibly undefined variables, unknown magic methods
2Unknown methods checked on all expressions
3Return types, types assigned to properties
4Basic dead code checks
5Checks argument types
6Report missing typehints
7Report partially wrong union types
8Report calling methods on nullable types
9Be strict about the mixed type
10Level 9 on steroids — reports implicit mixed from missing types too

Start at Level 0, Work Up

On an existing project, find the highest level that produces zero errors and start there:

vendor/bin/phpstan analyse --level=0  # clean? try 1, 2, 3...

Once you’ve found your starting point, commit the config and bump by one level per sprint. Do not try to jump from 0 to 9 in a single afternoon. That way lies existential despair.

Useful CLI Flags

# Cleaner output in CI
vendor/bin/phpstan analyse --no-progress

# Increase memory if analysis runs out (common on large codebases)
vendor/bin/phpstan analyse --memory-limit=1G

# Analyse a specific file
vendor/bin/phpstan analyse src/Services/UserService.php

Congratulations

You have survived the First Circle. The errors you see are not punishment — they are information. PHPStan is, annoyingly, almost always right.

II

The The Void of Mixed

Type Errors

Cannot call method on mixed. You reach for @phpstan-ignore. PHPStan weeps.

What is mixed?

mixed means PHPStan has no idea what type a variable is. It can be anything — a string, an object, null, a bowl of porridge. PHPStan refuses to make assumptions and will block any method call or property access on it.

Common sources of mixed:

  • Function parameters with no type hint
  • Array access ($data['key'] — PHPStan doesn’t know what’s inside)
  • Return values from untyped methods
  • Old PHP code written before PHP 7.0

Fix 1: Add Type Hints

The most direct solution. If you control the function, type it:

// Before — $data is mixed
function processUser($data): void {
    echo $data->getName(); // Error: Cannot call method on mixed
}

// After
function processUser(User $data): void {
    echo $data->getName(); // Fine
}

Fix 2: Type Narrowing

When the type genuinely varies, narrow it before use:

function renderValue(mixed $value): string {
    if ($value instanceof User) {
        return $value->getName();
    }

    if (is_string($value)) {
        return $value;
    }

    if (is_array($value)) {
        return implode(', ', $value);
    }

    return '';
}

PHPStan tracks the narrowed type through each branch. After instanceof User, it knows $value is a User.

Fix 3: PHPDoc Annotations

When you can’t change the function signature (third-party code, interface constraints), use a @var annotation to tell PHPStan what you know:

/** @var User $user */
$user = $container->get(User::class);
echo $user->getName(); // PHPStan now knows $user is User

Use this sparingly — it’s a promise to PHPStan that you’re making, not something it can verify.

Fix 4: Typed Array Shapes

PHPStan supports detailed array type declarations in PHPDoc:

/**
 * @param array{id: int, name: string, email: string} $data
 */
function createUser(array $data): User {
    return new User($data['id'], $data['name'], $data['email']);
}

Or for lists of items:

/**
 * @param array<int, User> $users
 * @return array<int, string>
 */
function getUserNames(array $users): array {
    return array_map(fn(User $u) => $u->getName(), $users);
}

Fix 5: Return Type Declarations

If other parts of your code are seeing mixed, it’s often because a method doesn’t declare its return type. Add it:

// Before
class UserRepository {
    public function find(int $id) { // returns mixed as far as PHPStan knows
        return $this->db->query("SELECT ...");
    }
}

// After
class UserRepository {
    public function find(int $id): ?User {
        // PHPStan now knows this returns User or null
    }
}

The assert() Escape Hatch

When you’re absolutely certain of a type and can’t narrow it any other way:

$result = $this->container->get('user');
assert($result instanceof User);
// $result is now typed as User

assert() throws at runtime if wrong, so this also improves runtime safety. PHPStan respects it for type narrowing.

III

The The Inferno of Level Nine

Strict Mode

You enabled level 9 on a Monday. You disabled it on a Tuesday. There is no shame in this.

What Changes at Higher Levels

Levels 0–5 are relatively forgiving. Levels 6–9 are where PHPStan earns its nickname.

Level 6 starts requiring type hints on all functions and methods. If you have a large codebase without types, this is where the flood begins.

Level 8 reports calling methods or accessing properties on nullable types — if a value could be null, PHPStan requires you to handle that case explicitly.

Level 9 makes mixed essentially unusable. If PHPStan can’t prove the type, you cannot use it.

Level 10 (introduced in PHPStan 2.0) takes level 9 further — it also catches implicit mixed coming from missing type hints, not just explicit mixed declarations.

Nullable Types: The Main Offender at Level 8

class OrderService {
    public function getTotal(int $orderId): float {
        $order = $this->repository->find($orderId); // returns ?Order

        return $order->getTotal(); // Error: Cannot call method on Order|null
    }
}

PHPStan is right. find() can return null. You have three options:

Option A — Handle null explicitly:

$order = $this->repository->find($orderId);
if ($order === null) {
    throw new OrderNotFoundException($orderId);
}
return $order->getTotal(); // PHPStan is happy: $order is Order here

Option B — Use the null coalescing operator or nullsafe operator:

return $this->repository->find($orderId)?->getTotal() ?? 0.0;

Option C — Change your repository to throw instead of returning null:

public function findOrFail(int $id): Order {
    $order = $this->find($id);
    if ($order === null) {
        throw new OrderNotFoundException($id);
    }
    return $order;
}

Option C is often the cleanest — it moves the null handling to one place.

Missing Return Types (Level 6)

// Level 6 error: Method has no return type specified
public function getUsers() {
    return $this->db->fetchAll('SELECT * FROM users');
}

// Fix
public function getUsers(): array {
    return $this->db->fetchAll('SELECT * FROM users');
}

If the method can return multiple types, use a union:

public function findUser(int $id): User|null {
    // or the shorthand: ?User
}

Void Return Types

Any method that doesn’t return a value should declare void. Level 6+ will flag this if missing:

// Before
public function sendWelcomeEmail(User $user) {
    $this->mailer->send($user->getEmail(), '...');
}

// After
public function sendWelcomeEmail(User $user): void {
    $this->mailer->send($user->getEmail(), '...');
}

A Practical Approach to Level 9

Don’t try to go from level 5 to level 9 in one commit. Instead:

  1. Bump one level at a time — fix the errors at each level before moving on
  2. Use a baseline for the current errors (see Circle IV) — lets you stop the bleeding without fixing everything at once
  3. Prioritise new code — require new files/classes to pass level 9, let old code stay in the baseline
  4. Add strict rules as a separate package for extra checks beyond the level system:
composer require phpstan/phpstan-strict-rules --dev
# phpstan.neon
includes:
    - vendor/phpstan/phpstan-strict-rules/rules.neon

parameters:
    level: 8

This package adds rules like requiring booleans in conditions, disallowing loose comparisons (==), and requiring strict operand types in arithmetic. It is complementary to the level system — not a substitute for any particular level.

The never Return Type

For completeness: a method that always throws and never returns can use never:

public function fail(string $message): never {
    throw new RuntimeException($message);
}

PHPStan understands this and will correctly narrow types after a call to such a method.

IV

The The Ancient Swamp

Legacy Codebase

You inherited 80,000 lines of untyped PHP and someone said 'just add PHPStan'. They are gone now.

The Problem

You can’t fix 3,000 PHPStan errors before your next deploy. But you also can’t ignore them forever. The baseline is the solution: freeze the current errors so new code must be clean, while you chip away at the old.

Generating a Baseline

vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

This creates a phpstan-baseline.neon file that records every current error. Include it in your config:

# phpstan.neon
includes:
    - phpstan-baseline.neon

parameters:
    level: 5
    paths:
        - src

Now running PHPStan reports zero errors — because all existing errors are suppressed. But the moment you introduce a new error, it will be caught.

Commit both files. The baseline is not a shameful secret. It’s a debt tracker.

What the Baseline Looks Like

parameters:
    ignoreErrors:
        -
            message: '#Cannot call method getName\(\) on App\\Models\\User\|null#'
            path: src/Services/UserService.php
            count: 3
        -
            message: '#Method App\\Repositories\\OrderRepository::find\(\) has no return type#'
            path: src/Repositories/OrderRepository.php
            count: 1

Each entry is scoped to a specific file and counts occurrences. If you fix some but not all, regenerate the baseline — it will reduce the count.

Regenerating the Baseline

After fixing a batch of errors, update the baseline to reflect the improvement:

vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

The baseline should shrink over time. Track it in git — watching the line count drop is oddly satisfying.

Incremental Adoption Strategy

Step 1 — Get to zero errors at your target level with a baseline:

vendor/bin/phpstan analyse --level=6 --generate-baseline phpstan-baseline.neon

Step 2 — Add PHPStan to CI (it now passes because of the baseline).

Step 3 — Require new code to be clean. Establish a team norm: no new PHPStan errors without a fix. The baseline covers old sins; new sins are forbidden.

Step 4 — Reduce the baseline in sprints. Allocate time each sprint to fix a chunk of baseline errors. When you fix a file completely, the baseline auto-shrinks on next regeneration.

Step 5 — Raise the level. Once the baseline for level N is empty, generate a new baseline at level N+1 and repeat.

Dealing with Generated Code

Auto-generated files (migrations, DTOs from API specs, Eloquent models) often produce many false errors. Exclude them:

parameters:
    excludePaths:
        - database/migrations/*
        - app/Models/Generated/*
        - src/Generated/*

Tips for Large Teams

  • Don’t regenerate the baseline manually without communicating. If two developers each fix different errors and both regenerate the baseline, you’ll get a merge conflict in phpstan-baseline.neon.
  • Fix errors file by file, not error type by error type. This minimises baseline merge conflicts.
  • Consider a periodic “PHPStan cleanup” PR where one person regenerates the baseline after a batch of fixes land.
V

The The Purgatory of @phpstan-ignore

False Positives

You fought PHPStan for two hours. It turns out PHPStan was right. It usually is.

First: Is It Actually a False Positive?

Before suppressing an error, take a moment. PHPStan’s false positive rate is genuinely low. If it’s flagging something, there’s often a real issue — or at minimum, code that could be clearer.

Ask yourself:

  • Can I add a type hint that resolves this?
  • Can I narrow the type with a guard clause?
  • Is there a PHPStan extension for this library (e.g., Larastan for Laravel)?

If the answer is “none of the above, and this is definitely fine at runtime” — then suppress it. But suppress it precisely.

Single-Line Suppression

/** @phpstan-ignore-next-line */
$result = $this->magicContainer->get('user'); // returns mixed for legitimate reasons

This is the nuclear option. It silences everything on the next line.

Targeted Suppression (Preferred)

Suppress only a specific error identifier:

/** @phpstan-ignore argument.type */
$this->process($this->container->get(SomeService::class));

To find the right identifier, look at the error output — PHPStan prints it in brackets after the message (available since PHPStan 1.10):

 42     Parameter #1 $service of method process() expects SomeService, mixed given.  [argument.type]

Run PHPStan on the offending file and copy the identifier directly from the output. Don’t guess — identifiers vary by error type and guessing a wrong one will silently do nothing.

Targeted suppression is better than @phpstan-ignore-next-line because it will fail if the error changes — a safety net against suppressing the wrong thing.

Inline @phpstan-ignore with Reason

Even better, add a reason:

// @phpstan-ignore argument.type (3rd-party SDK returns untyped mixed, see issue #142)
$client->callDynamic();

Future you will thank present you.

Config-Level Ignores

For systematic suppressions across the whole codebase, use phpstan.neon:

parameters:
    ignoreErrors:
        # Suppress a specific message pattern
        - '#Property .+ has no typehint specified#'

        # Suppress in a specific path only
        -
            message: '#Call to an undefined method .+::scopeActive\(\)#'
            path: app/Models/*

        # Suppress a specific error identifier
        -
            identifier: argument.type
            path: app/Legacy/*

Use patterns (regex) to be precise. Overly broad patterns will hide real errors.

PHPStan Extensions

The single most impactful thing you can do when working with a framework is install its PHPStan extension. These add framework-specific type knowledge that PHPStan doesn’t have out of the box.

Laravel — Larastan:

composer require larastan/larastan --dev
# phpstan.neon
includes:
    - vendor/larastan/larastan/extension.neon

Teaches PHPStan about Eloquent models, facades, collection generics, and magic methods.

Doctrine:

composer require phpstan/phpstan-doctrine --dev

Symfony:

composer require phpstan/phpstan-symfony --dev

PHPUnit:

composer require phpstan/phpstan-phpunit --dev

Writing Custom Stubs

When a third-party library has no extension and returns mixed everywhere, write a stub file to declare the types yourself:

// stubs/ThirdPartyClient.php
<?php

namespace ThirdParty;

class Client {
    /** @return array<string, mixed> */
    public function request(string $method, string $url): array {}
}

Register it in your config:

parameters:
    stubFiles:
        - stubs/ThirdPartyClient.php

PHPStan will use these declarations instead of the actual library files.

Dynamic Properties

PHP 8.2+ deprecated dynamic properties. If you’re using them (or your framework does), declare them explicitly or use #[\AllowDynamicProperties]:

// For your own classes, add a @property docblock
/**
 * @property string $dynamicField
 */
class MyModel extends BaseModel {}
VI

The The Abyss of Blocked Deploys

CI Pipeline

Your most common commit message is 'fix phpstan'. You are not alone.

Basic GitHub Actions Setup

# .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  # faster — no need for coverage in static analysis

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

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G

--no-progress removes the animated dots. CI logs don’t need them, and they add noise.

Caching the Result Cache

PHPStan caches analysis results between runs. Without caching this on CI, every run re-analyses everything from scratch.

      - name: Cache PHPStan
        uses: actions/cache@v4
        with:
          path: /tmp/phpstan-cache
          key: phpstan-${{ runner.os }}-${{ hashFiles('composer.lock', 'phpstan.neon') }}
          restore-keys: |
            phpstan-${{ runner.os }}-

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G

Add the cache path to your phpstan.neon:

parameters:
    level: 8
    paths:
        - src
    tmpDir: /tmp/phpstan-cache

On a warm cache, PHPStan only analyses changed files. On large codebases, this can cut analysis time by 80%.

Memory Limit

PHPStan is memory-intensive on large codebases. The default PHP memory limit is often too low.

vendor/bin/phpstan analyse --memory-limit=1G

Or set it in phpstan.neon for consistency across environments:

parameters:
    level: 8
    paths:
        - src
# Use a PHP ini override
php -d memory_limit=1G vendor/bin/phpstan analyse

Non-Blocking vs Blocking

When first introducing PHPStan to CI on an existing project, you might want it to report without blocking merges while you clear the baseline:

      - name: Run PHPStan (non-blocking)
        run: vendor/bin/phpstan analyse --no-progress || true
        # The '|| true' means the step always passes

Once you have a clean baseline, remove the || true and let it block.

Running Only on Changed Files

For speed on large monorepos, you can limit analysis to changed files. This is an advanced setup and has trade-offs (PHPStan can miss cross-file issues), but it’s useful for fast feedback in PRs:

      - name: Get changed PHP files
        id: changed-files
        run: |
          echo "files=$(git diff --name-only origin/main...HEAD | grep '\.php$' | tr '\n' ' ')" >> $GITHUB_OUTPUT

      - name: Run PHPStan on changed files
        if: steps.changed-files.outputs.files != ''
        run: vendor/bin/phpstan analyse --no-progress ${{ steps.changed-files.outputs.files }}

Output Formats

PHPStan supports multiple output formats for CI integration:

# GitHub Actions annotations (errors appear as inline PR annotations)
vendor/bin/phpstan analyse --error-format=github

# JSON output for custom tooling
vendor/bin/phpstan analyse --error-format=json > phpstan-report.json

# Checkstyle for Jenkins/other tools
vendor/bin/phpstan analyse --error-format=checkstyle

For GitHub Actions, the github format is particularly useful — errors appear as inline annotations directly on the diff.

      - name: Run PHPStan
        run: vendor/bin/phpstan analyse --no-progress --error-format=github --memory-limit=1G

The phpstan.neon for Teams

A solid team config:

includes:
    - phpstan-baseline.neon  # if you have one

parameters:
    level: 8
    paths:
        - src
        - tests
    tmpDir: /tmp/phpstan-cache
    checkMissingIterableValueType: false  # ease the pain on mixed arrays
    reportUnmatchedIgnoredErrors: true    # fail if an ignore rule no longer matches

reportUnmatchedIgnoredErrors: true is important — it ensures that when you fix an error, the ignore rule for it also gets cleaned up. Without it, your config accumulates dead suppressions.

You Made It

If PHPStan is in CI, passing cleanly, with a shrinking baseline — you’ve done it. You’ve tamed PHP Satan. He’s now just a slightly grumpy coworker who catches your type errors before they reach production.

Hall of Shame

The most cursed PHPStan messages, demystified. You are not the first. You will not be the last.

Cannot call method getName() on mixed.
Type

PHPStan has no idea what type this variable is. Trace it back to its source: add a return type to the method that produced it, or narrow the type with an instanceof check before calling the method.

Cannot call method getTotal() on App\Models\Order|null.
Type

Your method can return null but you're calling a method on it directly. Handle the null case first: check with `if ($order === null)`, use the nullsafe operator `$order?->getTotal()`, or change the method to throw instead of returning null.

Parameter #1 $callback of function array_map expects callable(int): mixed, Closure(string): string given.
Generics

The array type and the closure parameter type don't match. If your array is `array<int, string>`, your closure parameter must be `string`, not `int`. Check the PHPDoc on the array or narrow it with a typed variable.

Offset 'name' does not exist on array{}.
Type

PHPStan knows this array is empty at this point — you're accessing a key that can't exist. Often caused by conditional array building where PHPStan tracks only the empty branch. Add the key before the access, or use a non-empty array type in the PHPDoc.

Variable $result might not be defined.
Strict

The variable is only assigned inside a conditional branch. PHPStan sees a code path where it never gets assigned. Initialize it before the condition (`$result = null;` or a sensible default), or ensure all branches assign it.

Dead catch - Foo\BarException is never thrown in the try block.
Strict

Nothing inside the `try` block throws that exception type. Either the exception class changed, the throwing method was removed, or this catch was copy-pasted from somewhere else. Remove it or fix the exception type.

Return type of App\Services\Foo::getData() is PHPDoc type array<string, mixed> but contains mixed.
Generics

At level 9, returning `mixed` is not allowed even in arrays. You need to be more specific about the value type: `array<string, string>`, `array<string, int>`, or use a typed DTO instead of a generic array.

Method App\Services\UserService::findById() has no return type specified.
Strict

Level 6+ requires all methods to declare a return type. Add `?User` if it can return null, `User` if it always returns one, or `void` if it returns nothing. This is often a quick fix across a codebase with a find+replace.

Instanceof between App\Foo and App\Bar will always evaluate to false.
Strict

PHPStan knows these two classes are unrelated — `App\Foo` can never be an instance of `App\Bar`. Usually a sign of copy-paste error, a wrong variable name, or a class hierarchy that changed. Check your class relationships.

Negated boolean expression is always false.
Strict

The expression being negated is always `true`, so negating it is always `false`. This is dead code. PHPStan tracked the type through all branches and determined this condition can never be triggered.

Cannot access offset 'id' on mixed.
Type

Same root cause as "cannot call method on mixed" but for array access. The variable type is unknown. Add a PHPDoc `@var array{id: int, ...} $data` annotation, or type the function that produced this value.

Call to an undefined method Illuminate\Database\Eloquent\Builder::whereActive().
Legacy

PHPStan doesn't know about Eloquent local scopes or query macros. Install Larastan (`composer require larastan/larastan --dev`) and include its extension in your phpstan.neon. Larastan teaches PHPStan everything Laravel knows about Eloquent.

Method App\Repository::getAll() return type has no value type specified in iterable type array.
Generics

You declared `array` as a return type but didn't specify what's in the array. Use `array<int, User>` for a list, `array<string, mixed>` for an associative array, or suppress it globally with `checkMissingIterableValueType: false` if you're not ready for full generics yet.

Class App\Services\PaymentService constructor invoked with 2 parameters, 3 required.
Type

The constructor signature changed but the call site wasn't updated. Search all usages of `new PaymentService(` and update them. PHPStan caught a real bug here.

No error to ignore is matched by ignored error pattern #Call to undefined method .+::boot\(\)#.
CI

An ignore rule in your phpstan.neon or baseline no longer matches any real error — because you fixed it! Enable `reportUnmatchedIgnoredErrors: true` to catch these. Delete the stale rule.