Static Analysis with PHPStan and Psalm
Reviewed & published by Brayan K
By the end of this lesson you'll catch bugs without running your code — install and run PHPStan and Psalm, understand strictness levels and type inference, write precise @param/@return/@var PHPDoc (including generics), use a baseline to adopt analysis on legacy code, configure phpstan.neon/psalm.xml, and wire it all into CI.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Install and run PHPStan and Psalm with Composer
- How static analysis finds bugs without executing code
- Strictness levels (0-9) and type inference
- Write @param, @return, @var PHPDoc, including generics
- Use a baseline to adopt analysis on existing code
- Configure phpstan.neon / psalm.xml and run it in CI
1️⃣ Installing & Running the Tools
PHPStan and Psalm are the two standard static analyzers for PHP. You install them as dev-only dependencies (they're tooling, not part of your shipped app), then point them at your source folder. They read and reason about your code and print any problems — crucially, without ever running it. That's why they catch bugs on code paths your tests might never trigger.
# Install PHPStan and Psalm as dev-only dependencies with Composer.
composer require --dev phpstan/phpstan
composer require --dev vimeo/psalm
# Run them over your source folder WITHOUT executing your code:
vendor/bin/phpstan analyse src --level=6
vendor/bin/psalm
# Both read your code, reason about types, and print problems found.
# Nothing in src/ is actually run — that's why it's "static".Because nothing executes, analysis is fast and safe to run on every save. The next section shows the kind of bug it finds that a quick eyeball would miss.
2️⃣ Catching Bugs & Type Inference
The core power is type inference: the tool works out the type of every value from how it's assigned and used, then checks each operation against it. If getName() returns a string, calling ->save() on the result is impossible — and the analyzer says so, pointing at the exact line, before you ever run the file.
<?php
// PHPStan reads this WITHOUT running it and spots the bug:
// getName() returns a string, but we call strtoupper... fine — yet we
// then treat the result as a User and call ->save(). That can never work.
class User
{
public function getName(): string
{
return "Ada";
}
}
function greet(User $user): string
{
$name = $user->getName(); // inferred type: string
return $name->save(); // ❌ string has no method save()
}
?>That bug would only surface at runtime on the specific path that calls greet(). Static analysis surfaces it immediately, everywhere, for free.
3️⃣ Guiding the Analyzer with PHPDoc
PHP's native types can't say what's inside an array. PHPDoc annotations — @param, @return, @var — declare richer types in comments that the analyzer reads and enforces. The runtime ignores them, but PHPStan and Psalm use them to check every call site.
<?php
// PHP's native types can't express everything. PHPDoc fills the gaps,
// and the analyzer reads these comments to check every call site.
class Repository
{
/**
* @param int $id the user's id
* @return array<string, mixed> an associative row, or empty if missing
*/
public function find(int $id): array
{
return $id === 1 ? ["name" => "Ada", "age" => 36] : [];
}
/**
* @var list<string> a plain 0-indexed list of names
*/
public array $names = ["Ada", "Grace"];
}
$repo = new Repository();
$row = $repo->find(1);
echo $row["name"] . "\n"; // analyzer knows $row is array<string, mixed>
?>Now the tool knows find() returns an array<string, mixed> and that $names is a list<string> — far more than the bare array the engine sees.
4️⃣ Generics in Docblocks
PHP has no native generics, but the analyzers support them in PHPDoc. Write @return list<User> or @return array<int, User> and the tool tracks the element type through loops and calls — so accessing $u->name on each item is verified as valid.
<?php
// PHP has NO native generics, so analyzers read them from PHPDoc.
// @return array<int, User> says: a list keyed by int, of User objects.
// The tool then knows each element is a User and checks your usage.
class User
{
public function __construct(public string $name) {}
}
class UserList
{
/** @var list<User> */
private array $users = [];
public function add(User $u): void
{
$this->users[] = $u;
}
/** @return list<User> */
public function all(): array
{
return $this->users;
}
}
$list = new UserList();
$list->add(new User("Ada"));
foreach ($list->all() as $u) {
echo $u->name . "\n"; // analyzer knows $u is a User, so ->name is valid
}
?>This is how you get type-safe collections in PHP today: native code stays plain array, while the docblock gives the analyzer the precise element type to check against.
5️⃣ Config, Levels, Baselines & CI
You configure the tools in a file: phpstan.neon (NEON format) for PHPStan, psalm.xml for Psalm. There you set the strictness level (PHPStan 0–9; Psalm has errorLevel 1–8, lower = stricter), the paths to scan, and any rules to ignore. A new project can start at the strictest setting; a legacy one starts low and climbs.
# phpstan.neon — PHPStan's configuration file (NEON format).
parameters:
level: 6 # strictness 0 (loose) .. 9 (strict)
paths:
- src # which folders to analyse
- tests
ignoreErrors:
- '#Call to an undefined method LegacyThing::#'
# baseline records existing errors so only NEW ones fail the build:
# generate it with: vendor/bin/phpstan analyse --generate-baseline
includes:
- phpstan-baseline.neon
# psalm.xml — Psalm's equivalent, in XML.
# <psalm errorLevel="3">
# <projectFiles>
# <directory name="src" />
# </projectFiles>
# </psalm>For big existing codebases, generate a baseline (--generate-baseline): it records today's errors so only new ones break the build. Then add the analyzer to CI so every pull request is checked automatically — analysis becomes a gate, not a chore. Remember it complements, not replaces, your tests: analysis proves type and flow correctness, tests prove behavior.
Now you try the two core skills — adding the right PHPDoc, and configuring/running the tool.
Fill in each ___ using the 👉 hint, then check it against the Output panel.
One more. Set the strictness and run the analysis from the command line.
# 🎯 YOUR TURN — finish the PHPStan config and the command to run it.
# In phpstan.neon, set the strictness to the maximum (9):
# parameters:
# ___: 9 👉 the key that sets PHPStan's strictness
# paths:
# - src
# Then run the analyzer over the src folder (it does NOT run your code):
vendor/bin/phpstan ___ src # 👉 the subcommand that performs analysis
# ✅ Expected: PHPStan reports any type errors it finds in src/, or
# "[OK] No errors" if the code is clean at level 9.Common Errors (and the fix)
- Thousands of errors on first run — you pointed a strict level at legacy code. Start at a lower level, or generate a --generate-baseline so only new errors fail, then raise the bar gradually.
- "Ignored error pattern was not matched" — an entry in ignoreErrors no longer applies because you fixed the code. Remove the stale pattern from phpstan.neon.
- Analyzer "wrong" about an array's contents — native array tells it nothing. Add a PHPDoc generic like @return array<int, User> so it knows the element type.
- "Cannot open file phpstan.neon" — the config isn't in the project root or you didn't pass -c. Put it at the root, or run phpstan analyse -c path/to/phpstan.neon.
- Treating analysis as a replacement for tests — it isn't. It can't tell you the logic is right, only that the types and flow are sound. Keep your test suite and run both in CI.
Pro Tips
- 💡 Climb the levels. On existing code, raise PHPStan one level at a time and fix the new errors each step — far less daunting than jumping to 9.
- 💡 Make CI the gate. Run the analyzer alongside your tests on every pull request so type errors are caught in review, never in production.
- 💡 Lean on PHPDoc generics. Typing collections with list<T> and array shapes gives you most of the safety of generics that PHP itself still lacks.
📋 Quick Reference — Static Analysis
| Command / Concept | What It Does |
|---|---|
| composer require --dev phpstan/phpstan | Install PHPStan as a dev dependency |
| vendor/bin/phpstan analyse src | Analyse the src folder without running it |
| --level=0..9 | Set PHPStan strictness (higher = stricter) |
| @param / @return / @var | PHPDoc tags the analyzer reads for types |
| @return array<int, User> | Generics in docblocks (precise element types) |
| --generate-baseline | Record current errors so only new ones fail |
| phpstan.neon / psalm.xml | Config: paths, level, ignored rules |
| vendor/bin/psalm | Run Psalm using psalm.xml |
Mini-Challenge: Pass Level 9 Clean
No code is filled in this time — just a brief and an outline. Write a small typed class, point PHPStan at it at the strictest level, and fix every error until the report is clean. This is exactly the loop you'll run on real projects.
<?php
// 🎯 MINI-CHALLENGE: Make a small class pass PHPStan at level 9.
// No code is filled in — work from the steps below, then run it.
//
// 1. Write a class Cart with a private array of line items. Use PHPDoc
// generics to type it precisely: /** @var list<array{name: string, price: float}> */
// 2. Add addItem(string $name, float $price): void
// 3. Add total(): float that sums the prices (use a typed loop).
// 4. Create phpstan.neon with level: 9 and paths: [src].
// 5. Run vendor/bin/phpstan analyse and fix every reported error until
// you see "[OK] No errors".
//
// ✅ Goal: clean output at the strictest level, with no runtime needed.
// your code here
?>🎉 Lesson Complete!
- ✅ Static analysis finds bugs by reading code, without running it
- ✅ PHPStan and Psalm are the standard PHP analyzers, installed as dev dependencies
- ✅ Levels (0–9) and type inference let you adopt strictness gradually
- ✅ PHPDoc (@param/@return/@var) and generics give the tool precise types
- ✅ A baseline lets you adopt analysis on legacy code; config lives in phpstan.neon/psalm.xml
- ✅ Run it in CI on every pull request — it complements, not replaces, your tests
- ✅ You've reached the end of the PHP course — congratulations!
Practice quiz
What is static analysis?
- Inspecting source code for bugs without executing it
- Profiling memory usage at runtime
- Minifying code for production
- Running your test suite repeatedly
Answer: Inspecting source code for bugs without executing it. Static analysis reads and reasons about your code without running it, catching whole classes of bugs (type errors, undefined methods) before the program ever executes.
Which two tools are the standard static analyzers for PHP?
- PHPUnit and Pest
- Composer and Packagist
- PHPStan and Psalm
- Xdebug and Blackfire
Answer: PHPStan and Psalm. PHPStan and Psalm are the two leading static analysis tools for PHP. Both find type errors and bugs by analyzing code without running it.
What do PHPStan's levels (0 to 9) control?
- How long the analysis runs
- How strict the checks are, with higher numbers catching more potential issues
- How many files it scans
- How many CPU cores it uses
Answer: How strict the checks are, with higher numbers catching more potential issues. PHPStan levels run from 0 (loosest) to 9 (strictest). Each level adds more rules, so you can adopt the tool gradually and raise the bar over time.
What does type inference let a static analyzer do?
- Run the code to see what type appears
- Rename your classes automatically
- Guess variable names
- Work out the type of a value from how it is assigned and used, even without an annotation
Answer: Work out the type of a value from how it is assigned and used, even without an annotation. Type inference means the tool figures out a value's type from context, so it can flag a mismatch even where you did not write an explicit type.
What is a PHPDoc annotation like @param string $name used for?
- It documents and declares types in a comment, which the analyzer reads to check usage
- It changes the runtime behavior of the function
- It generates HTML documentation only
- It is ignored by everything
Answer: It documents and declares types in a comment, which the analyzer reads to check usage. PHPDoc tags such as @param, @return, and @var declare types in comments. Static analyzers read them to verify how values flow through your code, including types PHP's native syntax cannot express.
What is the purpose of a baseline file?
- It deletes all existing errors
- It records the current known errors so the tool only reports NEW ones, letting you adopt analysis on legacy code
- It speeds up the analysis
- It stores your database schema
Answer: It records the current known errors so the tool only reports NEW ones, letting you adopt analysis on legacy code. A baseline snapshots existing errors so they are suppressed; the tool then only fails on new issues. This lets a big legacy codebase adopt strict analysis without fixing everything at once.
Where do you configure PHPStan and Psalm respectively?
- composer.json only
- phpstan.json and psalm.json
- config.php for both
- phpstan.neon and psalm.xml
Answer: phpstan.neon and psalm.xml. PHPStan reads phpstan.neon (NEON format) and Psalm reads psalm.xml. Each defines which paths to scan, the strictness level, and any ignored rules.
How are generics expressed in PHP static analysis?
- Only inside .neon files
- With native PHP syntax like List<int>
- In PHPDoc, for example @return array<int, User> or @param list<string> $names
- They are not supported at all
Answer: In PHPDoc, for example @return array<int, User> or @param list<string> $names. PHP has no native generics, so analyzers read them from PHPDoc, e.g. @return array<int, User> or Collection<User>, giving precise element types the engine itself does not track.
What is the recommended way to keep code passing analysis over time?
- Run it once before release and never again
- Run the analyzer in your CI pipeline so every push and pull request is checked
- Only run it on your own machine
- Disable it whenever it fails
Answer: Run the analyzer in your CI pipeline so every push and pull request is checked. Adding the analyzer to CI means every commit is automatically checked, so type errors and bugs are caught in review rather than in production.
How does static analysis relate to your tests?
- It complements tests: analysis proves type and flow correctness, tests prove behavior is correct
- It is the same thing as testing
- Tests make analysis unnecessary
- It replaces unit tests entirely
Answer: It complements tests: analysis proves type and flow correctness, tests prove behavior is correct. Static analysis and tests cover different things. Analysis catches type mistakes and impossible code paths without running anything; tests verify actual behavior. You want both.
Continue this course
- Previous: Fibers & Async PHP
- Quick reference: PHP cheat sheet
Frequently asked questions
What is static analysis and how is it different from running tests?
Static analysis inspects your source code without executing it, reasoning about types and control flow to find bugs the moment you write them — calling a method that does not exist, passing the wrong type, an unreachable branch, a possibly-null value used unsafely. Tests are dynamic: they run the code and check that its behavior matches expectations. The two are complementary. Analysis can prove that a whole category of mistakes is absent everywhere in your code, even on paths your tests never hit; tests prove that the behavior you do exercise is actually correct. Mature projects run both.
What do PHPStan's levels (0 to 9) actually mean?
PHPStan's level is a single number from 0 (loosest) to 9 (strictest) that controls how many rules are switched on. Level 0 catches basic mistakes like calling undefined functions; each step up adds checks for things like undefined methods, wrong argument types, possibly-undefined variables, and finally strict null and mixed-type handling at the top. The point of levels is gradual adoption: you can start a legacy project low, get value immediately, and raise the level one notch at a time as you fix issues. A brand-new project can usually start at the maximum straight away.
Why do I need PHPDoc annotations if PHP already has type declarations?
Native PHP type declarations cover scalars, classes, unions, and nullables, but they cannot express everything an analyzer can use. PHPDoc tags like @param, @return, and @var add precision the language lacks: the element type of an array (@return array<int, User>), a plain list versus a map (list<string> vs array<string, int>), array shapes (array{name: string, age: int}), and generics on your own collections. The runtime ignores these comments, but PHPStan and Psalm read them and check every call site against them, so they catch mistakes that native types simply cannot describe.
What is a baseline file and when should I use one?
A baseline is a generated file that records every error currently reported, effectively saying do not warn me about these again. After you create it (for PHPStan, with --generate-baseline), the tool suppresses all the known issues and only fails on new ones introduced from now on. This is the standard way to adopt strict analysis on a large existing codebase: you get immediate protection against new bugs without having to stop and fix thousands of pre-existing warnings first. Over time you whittle the baseline down. A fresh project does not need one.
Should I run static analysis in CI?
Yes. Running PHPStan or Psalm locally is useful, but it only helps the person who remembers to run it. Adding the analyzer as a step in your continuous integration pipeline means every push and every pull request is checked automatically, and the build fails if new type errors appear. That turns analysis into a reliable gate: problems are caught in code review rather than slipping into production. It is a few lines in your CI config and pairs naturally with running your test suite in the same pipeline.