Static Analysis: PHPStan, Psalm, and Gradual Typing for Legacy Code

Understand what a PHP static analyzer actually does (type inference without running the code), how PHPStan's 0-9 strictness levels work, how to write PHPDoc generics (@template, array<string,int>) so the analyzer understands collections precisely, how baseline files let a legacy codebase adopt analysis gradually, what dead-code and impossible-condition detection catch, how architecture rules enforce layer boundaries, and how to wire all of it into CI.

PHP is dynamically typed at heart — a variable can hold an int today and a string tomorrow, and the interpreter will happily let you call a method that doesn't exist until the exact line executes at runtime and blows up in production. Static analysis tools exist to catch that class of bug before the code ever runs: PHPStan and Psalm read your source, infer the type of every expression the way a compiler would, and flag anything that couldn't possibly be correct — calling a method that isn't on the inferred type, passing a string where an int is required, referencing an array key that was never set on that branch. They don't execute a single line of your program; they build a model of what every value's type must be from declarations, assignments, and control flow, and report contradictions. On a legacy codebase with zero type hints, running one of these tools for the first time can report thousands of errors — which is exactly the problem baseline files and gradual leveling exist to solve.

How type inference in static analysis actually works

PHPStan and Psalm both build an abstract model of every variable's possible type at every point in the code, without executing anything. Given function add(int $a, int $b): int { return $a + $b; }, the analyzer doesn't need to guess $a's type — the parameter type hint tells it directly. The interesting work happens with untyped or loosely typed code: given $x = $condition ? 'a' : 1;, the analyzer infers $x as the union type 'a'|1 (or widened to string|int) at that point, and if a later line does strlen($x), it reports an error because int isn't accepted by strlen()'s string parameter on the branch where $x is 1.

This inference flows through control structures too: after if (is_string($x)) { /* here, $x is known to be string */ }, the analyzer narrows the type inside that branch — this is called type narrowing or refinement, and it's why instanceof checks, is_array()/is_string() guards, and assert() calls are valuable to an analyzer even though they're runtime no-ops from PHP's own perspective. The more of these guards and type hints exist in the code, the tighter the analyzer's inferred types become, and the more real bugs it can catch — inference quality is directly proportional to how much type information the code actually declares.

PHPStan strictness levels 0-9

PHPStan organizes its checks into ten cumulative strictness levels, 0 through 9, set via --level on the command line or level: in phpstan.neon. Level 0 only catches unknown classes, functions, and basic syntax-adjacent mistakes — almost nothing a competent developer would write. Each higher level adds a new category of check on top of everything below it: level 2 starts checking unknown methods on classes; level 5 starts checking argument types passed to methods and functions; level 6 requires explicit typehints be declared where missing; level 8 starts flagging calls to methods on a possibly-null value without a null check; level 9 (max) is the strictest, treating mixed type usage itself as suspect and requiring nearly complete type coverage.

The practical strategy for a real codebase is never to jump straight to level 9. Start at a low level that the existing code already mostly passes, fix what surfaces, then ratchet the level up one notch at a time — each increment reveals a new category of previously-invisible bugs without drowning the team in thousands of simultaneous errors from ten categories at once.

• Level 0-1: unknown symbols, obviously wrong syntax-level mistakes • Level 2-4: unknown methods/properties, some return-type checks • Level 5-6: argument type checking, missing type hints flagged • Level 7-8: nullable/possibly-null handling, stricter array access • Level 9 (max): near-complete type coverage, mixed usage scrutinized

PHPDoc generics: @template and precise array shapes

PHP's native type system has no generics — you cannot write class Collection<T> the way TypeScript or Java can. PHPStan and Psalm both fill this gap by parsing an extended PHPDoc syntax as if it were part of the language, most importantly @template. A class declares @template T in its docblock, then uses T inside @param/@return annotations on its methods (@return T, @param T $item) to describe a container that is generic over the type it holds, and every call site gets that generic instantiated based on how the class was actually used — new Collection<User>() (expressed via @var Collection<User> at the point of construction, since PHP has no angle-bracket syntax) lets the analyzer know ->first() returns a User, not mixed.

For plain arrays, native PHP only ever says array — no information about what's inside. PHPDoc array-shape syntax fixes this: @param array<string, int> $scores tells the analyzer every key is a string and every value is an int, so $scores['alice'] . 'x' gets flagged as a type error the moment you concatenate an int with a string incorrectly, and @param array{name: string, age: int} $user describes a fixed-shape associative array (like a lightweight anonymous struct) with a specific key set and per-key types — catching a typo'd array key ($user['naem']) as a static error instead of a silent null at runtime.

Baseline files: adopting analysis on a legacy codebase without a rewrite

Running phpstan analyse on a 200,000-line legacy codebase for the first time routinely produces thousands of pre-existing errors — the codebase was never written with an analyzer's eye. Fixing all of them before CI can enforce anything is not realistic, and it blocks the team from getting the tool's value (catching *new* bugs) for months. phpstan analyse --generate-baseline solves this by snapshotting every currently-reported error into a phpstan-baseline.neon file, which is then included in the main config — from that point on, PHPStan only reports errors that are NOT in the baseline, meaning new code, or any edit to existing code that introduces a new problem, gets caught immediately, while the pre-existing debt is silently ignored until someone deliberately pays it down.

The baseline is meant to shrink over time, not sit forever: as developers touch a file for unrelated reasons, they're encouraged to fix the baselined errors in that file and regenerate the baseline (dropping those now-fixed lines out of it), and some teams enforce a policy of the baseline file only being allowed to shrink in a PR diff, never grow — turning 'don't add new debt to the baseline' into an actual CI-enforced rule rather than a suggestion.

Dead-code and impossible-condition detection

Because the analyzer builds a real type model, it can spot two closely related classes of logic bug that have nothing to do with type errors per se. Dead-code detection flags code that provably can never execute — a return statement followed by more statements in the same block, a private method that's never called anywhere in the class, or a branch of an if/else that's unreachable given what the analyzer knows about the condition's type. Impossible-condition detection flags a comparison or condition that is always true or always false given the inferred types — if ($count === null) where $count was type-hinted int and can never be null, or $status === 'active' && $status === 'inactive' in the same expression, which can literally never both be true.

Both of these are usually not deliberate bugs so much as symptoms of something drifting: a type hint was tightened elsewhere and a defensive null check downstream became dead weight, or a refactor left an old code path unreachable. They're worth fixing because impossible conditions especially tend to mask a *different* real bug the developer meant to guard against but expressed incorrectly.

Architecture rules: enforcing layer boundaries

Beyond type correctness, tools like Deptrac (and PHPStan's own rule extensions) let a team encode architectural constraints as enforceable rules rather than just code-review conventions — for example, 'the Domain layer must never depend on the Infrastructure layer' or 'Controllers may not directly reference an Eloquent/Doctrine model class, only a Repository interface.' Deptrac works by defining named 'layers' as glob patterns or namespace prefixes over the codebase, then declaring which layers are allowed to depend on which, and scanning every use statement and type hint in the codebase to report any dependency edge that violates the declared ruleset.

This matters because layer-boundary violations are exactly the kind of thing that's easy to state in an architecture diagram and easy to quietly violate in practice — one developer adds use App\Infrastructure\StripeClient; inside a Domain-layer class under deadline pressure, it passes code review because the reviewer doesn't have the whole dependency graph memorized, and six months later the Domain layer is entangled with three different infrastructure concerns it was designed to be independent of. An architecture-rule check turns that violation into a CI failure the moment it's introduced, the same way a type error would be.

CI integration and a gradual typing strategy

Static analysis earns its keep only when it runs automatically, on every change, before code merges — a composer.json script ("phpstan": "phpstan analyse --no-progress") invoked from a CI pipeline step, exiting non-zero (failing the build) on any non-baselined error. Running it locally via a pre-commit hook too (covered in the Code Quality Toolchain lesson) catches problems before they even reach CI, but CI is the actual enforcement point that can't be skipped or forgotten.

For a legacy codebase, the realistic adoption path is: generate a baseline at whatever level the codebase currently tolerates (often level 0-2 to start), get that baseline merged and CI enforcing 'no new errors' from day one, then run two parallel efforts over time — raising the configured level in small increments as the team fixes what each new level surfaces, and shrinking the baseline file as developers touch old files. Neither has to finish before the other starts, and both are driven by the same principle: static analysis should get stricter monotonically, never looser, because a level or baseline that's allowed to regress stops being trustworthy as a gate.

phpstan.neon: level, baseline, and generic PHPDoc

level: max is equivalent to the highest numbered level PHPStan currently supports and moves forward as new levels are added — pin an explicit number if you want stability across PHPStan upgrades. excludePaths is a coarser escape hatch than the baseline — useful for a directory being deleted rather than fixed.

# phpstan.neon
parameters:
    level: 6
    paths:
        - src
    excludePaths:
        - src/Legacy/*

includes:
    - phpstan-baseline.neon

# In PHP source, generic collection via PHPDoc:
/**
 * @template T
 */
final class Collection
{
    /** @var T[] */
    private array $items = [];

    /** @param T $item */
    public function add($item): void
    {
        $this->items[] = $item;
    }

    /** @return T */
    public function first()
    {
        return $this->items[0];
    }
}

/**
 * @param array<string, int> $scores
 * @param array{name: string, age: int} $user
 */
function summarize(array $scores, array $user): string
{
    return "{$user['name']} scored {$scores['total']}";
}

Code Examples

Native array type hints give PHPStan nothing to check the key against, so this typo would run silently and always evaluate the undefined-key access as falsy (with a runtime warning at best). With the @param array{id: int, email: string, isActive: bool} $user shape declared, PHPStan knows the only valid keys are id, email, and isActive, and flags 'ative' as an offset that does not exist on the array shape — a compile-time-style error for a bug that would otherwise only surface when a real welcome email silently never sends. This is exactly the class of bug array-shape PHPDoc exists for: a typo'd string key that native array typing has no way to catch.

if ($user['ative']) { Without @template, pop()'s return type would be inferred as mixed at every call site, and $order->id would be flagged (at higher levels) as accessing a property on an unknown type. The @template T plus the @var TypedStack<Order> annotation at construction lets PHPStan substitute T=Order throughout every subsequent call on that instance, so pop() is understood to return Order specifically — giving generic-container code the same precision a language with real generics would provide. Expected result: PHPStan (level 5+): no errors. Removing the @var TypedStack<Order> annotation and running --level 8 would report ->id as accessed on mixed.

<?php

declare(strict_types=1);

/**
 * @param array{id: int, email: string, isActive: bool} $user
 */
function sendWelcomeEmail(array $user): void
{
    // Typo: 'ative' instead of 'isActive'. Native PHP: silent null, no error
    // until this line executes in production for a real user.
    if ($user['ative']) {
        mail($user['email'], 'Welcome', 'Hi there!');
    }
}
<?php

declare(strict_types=1);

/**
 * @template T
 */
final class TypedStack
{
    /** @var list<T> */
    private array $items = [];

    /** @param T $item */
    public function push($item): void
    {
        $this->items[] = $item;
    }

    /** @return T */
    public function pop()
    {
        if ($this->items === []) {
            throw new \UnderflowException('Stack is empty.');
        }
        return array_pop($this->items);
    }
}

final class Order
{
    public function __construct(public readonly int $id) {}
}

/** @return TypedStack<Order> */
function buildOrderStack(): TypedStack
{
    /** @var TypedStack<Order> $stack */
    $stack = new TypedStack();
    $stack->push(new Order(1));
    return $stack;
}

$stack = buildOrderStack();
$order = $stack->pop(); // PHPStan infers $order is Order, not mixed
echo $order->id;        // safe: ->id is known to exist on Order

Gradual adoption on a legacy codebase: baseline shrinks, level rises

Two independent ratchets — strictness level and baseline size — that only ever move in the stricter direction.

Day 1                          Month 6                        Month 18
  level: 2                       level: 5                        level: 8
  baseline: 4,300 errors   -->   baseline: 900 errors     -->    baseline: 40 errors
  (snapshot, frozen)             (shrinking as files touched)    (nearly clean)

  CI enforces: no error          CI enforces: no error           CI enforces: no error
  outside the baseline           outside the baseline            outside the baseline
  at level 2                     at level 5                      at level 8

  New code: fully checked at the CURRENT level from day one, every day.
  Old code: checked only as far as the baseline lets it be, shrinking over time.

Why teams run PHPStan at a lower level than they'd like, on purpose

Large PHP codebases (WordPress plugins, older Symfony/Laravel apps predating strict types) commonly settle on level 5 or 6 rather than max, not because higher levels aren't valuable but because the marginal error count per level increase grows non-linearly on code that predates static analysis entirely. The pragmatic move is treating the level as a budget: pick the highest level the team can act on within a sprint or two, hold it there until the baseline for that level is meaningfully paid down, then move up — chasing level 9 on day one just produces a baseline file too large for anyone to trust or ever finish paying off.

// "We'll just regenerate the baseline whenever CI complains" // phpstan.neon has no excludePaths policy and the baseline is regenerated // on every PR that fails, absorbing new errors right back into it.

// Baseline regeneration only happens as a deliberate, reviewed act — // e.g. composer phpstan:baseline run locally when paying down debt — // never as an automated 'make CI pass' step inside the PR pipeline itself.

Regenerating the baseline automatically whenever a PR introduces a new error defeats the entire purpose of static analysis: it turns 'no new errors allowed' into 'any error becomes permanently acceptable the moment it exists,' silently absorbing new bugs into the same pile that was supposed to only ever shrink.

What is the primary purpose of a PHPStan baseline file? A. It disables static analysis entirely for files listed in it B. It snapshots all currently-reported errors so CI only fails on NEW errors, letting a legacy codebase adopt analysis gradually instead of fixing everything before CI can enforce anything C. It automatically fixes type errors it finds D. It raises the strictness level to the maximum automatically

B. It snapshots all currently-reported errors so CI only fails on NEW errors, letting a legacy codebase adopt analysis gradually instead of fixing everything before CI can enforce anything — A baseline freezes the current error set so the analyzer only reports errors that are not already in it, letting CI enforce 'no new problems' from day one on a codebase that has thousands of pre-existing ones, while that debt is paid down separately over time.

Why does @param array<string, int> $scores catch bugs that a plain array $scores type hint cannot? A. It makes the array immutable at runtime B. It tells the analyzer the key and value types, so operations that assume the wrong type on a key (like a typo'd key or wrong-typed value use) are flagged statically C. It converts the array into a real generic collection object D. It is purely documentation with no effect on analysis

B. It tells the analyzer the key and value types, so operations that assume the wrong type on a key (like a typo'd key or wrong-typed value use) are flagged statically — Native array carries no information about what's inside it, so the analyzer can't check anything about keys or values. The array<string, int> PHPDoc shape gives it that information, enabling checks (wrong value type used, incompatible key type) that would otherwise be invisible until runtime.

What does an architecture rule tool like Deptrac primarily check? A. That every function has a return type declared B. That declared layer boundaries (e.g. Domain must not depend on Infrastructure) are not violated by any actual use statement or type hint in the code C. That mutation testing coverage exceeds a threshold D. That every class has an associated unit test

B. That declared layer boundaries (e.g. Domain must not depend on Infrastructure) are not violated by any actual use statement or type hint in the code — Deptrac scans the real dependency graph (use statements, type hints) against declared layer rules and reports any dependency edge that crosses a forbidden boundary — turning an architecture diagram's intent into an enforceable, automatically-checked rule rather than a code-review convention.

Given a legacy OrderRepository class with no return-type hints anywhere and PHPDoc that's entirely absent, describe the concrete first three steps you'd take to bring it under static analysis without touching its behavior, in the order you'd do them. Hint: Think: run the analyzer at a low level and generate a baseline first (so nothing breaks yet), then add native return/param type hints where they're unambiguous (fastest win, no PHPDoc needed), then add @param array-shape or @template annotations only where a plain type hint genuinely can't express what's needed.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.