Published on
PHPStan finds errors, sure. On a project that has been growing for years, something else matters more to me: I can use it to set rules and enforce them. Whatever goes red in the pipeline doesn't get in any more. It doesn't matter whether a colleague wrote it on a Friday evening or an assistant that was too fast.
You won't get rid of the old errors in one go. From day one, though, you stop adding new ones, and the old ones become fewer on the side, file by file.
Everything here comes from projects where it runs like this: PHPStan 2.1 on PHP 8.4, level 8. The oldest has around 3,100 entries in the baseline, the youngest a good 180.

Getting started: level and baseline
PHPStan has levels 0 to 10. The higher the level, the more it checks. Level 0 finds unknown
classes and methods, from level 5 argument types are compared, and level 8 forbids access to
something that could be null. On an old project, that means thousands of errors on the first
run.
The configuration is small:
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
Then one command records the current state as "known":
vendor/bin/phpstan analyse --generate-baseline
What comes out is phpstan-baseline.neon, a list of errors PHPStan skips from now on:
parameters:
ignoreErrors:
-
message: '#^Argument of an invalid type array\|false supplied for foreach, only iterables are supported\.$#'
identifier: foreach.nonIterable
count: 1
path: src/Legacy/ReportExport.php
From then on phpstan analyse is green. It only goes red when something new is added.
An entry consists of message, file and count. There is no line number in it. If you insert ten lines higher up in the file, nothing shifts. If you introduce the same error a third time in a file with two known ones, though, the count is exceeded and PHPStan reports it.
The baseline shrinks by itself
This is what I like best about it. When you fix an error that is in the baseline, PHPStan complains:
Ignored error pattern #^Argument of an invalid type array\|false …$# in path
src/Legacy/ReportExport.php was not matched in reported errors.
The entry is left over and has to go. Instead of hunting for it in the file by hand, you can
regenerate the baseline and read the diff. Lines may only have disappeared, or a count gone
down. If anything new shows up, you just waved an error through. A commit that cleans up a
class therefore always consists of the code and the shorter baseline. The review shows both,
and the number should only get smaller.
Two things you should know before relying on it.
--generate-baseline waves everything through. One call, and every new error is in the file
too. The line count (git diff --numstat) doesn't help here: if one entry drops out and a new
one comes in, the file stays the same length. Only an entry-by-entry comparison is reliable. The
pipeline reads the baseline from the target branch and from the merge request and fails as
soon as a pair of message and file is new or its count has gone up.
Big refactorings also create noise. If you rename a method, the message changes in every entry that mentions it. Such commits are best kept separate.
Stricter than level 8: extensions
Even level 10 doesn't check everything I'd like. For that there are extensions that you add via
includes. These are the ones I run:
| Package | What it additionally requires |
|---|---|
phpstan/phpstan-strict-rules |
Strict comparisons, no expressions in conditions that aren't bool |
ergebnis/phpstan-rules |
declare(strict_types=1) in every file, final for classes, no null defaults |
phpstan/phpstan-deprecation-rules |
Reports every call to something marked @deprecated |
larastan/larastan |
Knows Laravel: Eloquent models, facades, collections |
Each package is one line:
includes:
- phpstan-baseline.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
- vendor/ergebnis/phpstan-rules/rules.neon
On an old project, each of them brings new errors. That is exactly what the baseline is good for. You switch the rules on, freeze the current state, and from the next commit they apply to everything newly written. If a rule doesn't fit the project, you can switch it off on its own:
parameters:
ergebnis:
noExtends:
enabled: false
A custom rule in 30 lines
At some point you'll want to check something that doesn't exist ready-made. For me it was this:
class constants need a type. Since PHP 8.3, const int MAX = 5; works, and once that's in
place it should stay that way.
A rule is a class with two methods. getNodeType() says which kind of syntax node it handles.
processNode() gets every such node and returns a list of errors:
/**
* @implements Rule<ClassConst>
*/
final class RequireClassConstantTypeRule implements Rule
{
public function getNodeType(): string
{
return ClassConst::class;
}
/**
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if ($node->type !== null) {
return [];
}
return [
RuleErrorBuilder::message('Class constants must have an explicit type.')
->identifier('classConstant.missingType')
->build(),
];
}
}
It is registered in phpstan.neon:
rules:
- App\PHPStan\Rules\RequireClassConstantTypeRule
From now on the rule applies to all the code, and the baseline takes in whatever already
violates it. Always set the identifier. It lets you ignore the error selectively later, and
it shows up in the baseline next to the message.
If you don't know how to get at a particular node, look at the syntax tree. The node types come
from nikic/php-parser and are named as in the grammar (ClassConst, MethodCall, New_).
Then there is Scope. It knows which class and method you are in and what type an expression
has.
Sanity check
A rule that never goes red doesn't get noticed. So every new rule gets a throwaway file once that violates it:
final class Demo
{
public const MAX = 5;
}
If phpstan analyse goes red and the message makes sense, the rule works. Then the file goes
away again. If you want to secure the rule for good, turn it into a test with RuleTestCase,
which ships with PHPStan.
What PHPStan can't do
PHPStan checks what is in a file. Where the file lives, what it is called and who may use whom is outside its scope. For that I use PHPArkitect. It works with the same baseline principle. So PHPStan takes care of the content, PHPArkitect of the structure.