Static Analysis (PHPStan/Psalm)
Tests catch bugs by running your code and checking the results. Static analysis catches a different — and in some ways larger — class of bugs without running the code at all: it reads your source, builds an understanding of every variable's possible types, and flags places where that understanding doesn't add up. This page covers what static analysis catches that tests typically miss, how PHPStan's level system works, a concrete example of a bug it would catch, and wiring it into CI as a merge gate.
What static analysis catches that tests don't
Tests only check the paths you actually thought to write a test for. A function with five branches and no tests for two of them has two completely unverified code paths. Static analysis tools like PHPStan and Psalm instead examine every line of every branch, whether or not a test ever executes it, looking specifically for:
Type errors — passing a
stringwhere a function's parameter is typedint, or calling a method that doesn't exist on the inferred type of a variable.Dead code — a branch that can never execute, like an
ifcondition that's always false given the types involved, or code after areturnstatement.Impossible conditions — comparisons that can never be true (or always are), often revealing a typo or a leftover fragment from a refactor.
Undefined variables and properties — referencing a variable that was never set on a particular code path, or a class property that doesn't exist.
None of these require a single test to run — they're findable purely by reading and reasoning about the code, which is exactly what a static analyzer automates.
A concrete example
Consider a function that looks up a user by id and returns their display name.
A bug PHPStan would catch
<?php
function findUser(int $id): ?User
{
// returns null if no user with this id exists
return UserRepository::find($id);
}
function greet(int $id): string
{
$user = findUser($id);
return 'Hello, ' . $user->name;
}findUser() is typed to return ?User — a User or null. The
greet() function calls $user->name without ever checking whether
$user is null first. This compiles fine, and a test that happens
to only pass in ids that exist will never expose the problem. But the
moment someone calls greet() with an id that isn't in the database,
it throws a fatal error trying to access a property on null.
PHPStan sees the ?User return type and flags the unchecked access
immediately, without needing a test that exercises the missing-user
case.
Running PHPStan against this file
vendor/bin/phpstan analyse src
------ -------------------------------------------------------------- Line src/greet.php ------ -------------------------------------------------------------- 13 Cannot access property $name on User|null. ------ -------------------------------------------------------------- [ERROR] Found 1 error
The fix is a simple null check (or letting greet() return a fallback string when no user is found), but the value here is that PHPStan found it before it ever became a production incident.
PHPStan's levels: 0 through 9
Running strict static analysis against an existing, untyped codebase for the first time can produce thousands of errors — which is overwhelming and not a realistic starting point. PHPStan handles this with a level system: level 0 checks only the most basic, almost certainly-real problems (unknown classes, unknown functions), and each level up to 9 adds progressively stricter rules, culminating in near-complete type coverage that resembles a statically typed language.
phpstan.neon
parameters:
level: 5
paths:
- src
- testsA common strategy for an existing codebase is to start at a low level like 1 or 2, fix (or explicitly ignore) what it reports, commit that as a baseline, and then ratchet the level up over time as the codebase's type coverage improves. Trying to jump straight to level 9 on a large legacy project usually just means the tool gets turned off in frustration.
Generating a baseline for legacy code
PHPStan can snapshot every currently-existing error into a baseline file, so you can adopt it on day one without fixing years of accumulated issues first — new code is then held to the standard while old code is grandfathered in until someone gets around to it.
Generate a baseline of existing errors
vendor/bin/phpstan analyse --generate-baseline
This creates a phpstan-baseline.neon file listing every current error by file and line. Once it's included from phpstan.neon, only new errors introduced after the baseline was generated cause the analysis to fail — which is exactly the behavior you want when introducing static analysis to a codebase that already has years of history.
Integrating into CI as a merge gate
Static analysis earns its keep when it runs automatically on every pull request, not just when someone remembers to run it locally. A typical CI step installs dependencies and then runs the analyzer, failing the build (and blocking the merge) if it reports any errors.
A CI step running PHPStan
- name: Install dependencies run: composer install --no-interaction - name: Run PHPStan run: vendor/bin/phpstan analyse --error-format=github
Wiring this in as a required check means a pull request that introduces a type error can't be merged until it's fixed — the same role a required, passing test suite plays, but catching a different category of mistake, and catching it in seconds rather than only surfacing once someone happens to exercise the broken code path.
Static analysis reads code without running it, catching type errors, dead code, and impossible conditions that untested branches would otherwise hide.
PHPStan's levels (0-9) let you start lenient and tighten strictness over time rather than facing thousands of errors on day one.
A generated baseline lets you adopt static analysis on an existing codebase without fixing every historical issue immediately.
Run it in CI as a required check, the same way you'd require the test suite to pass, so type errors can't slip into the main branch.