Skip to content

Adding PHPStan to an existing project

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.

The PHPStan configuration: include the baseline, level 8, path src

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:

NEON
includes:
    - phpstan-baseline.neon
parameters:
    level: 8
    paths:
        - src

Then one command records the current state as "known":

Bash
vendor/bin/phpstan analyse --generate-baseline

What comes out is phpstan-baseline.neon, a list of errors PHPStan skips from now on:

NEON
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:

NEON
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:

NEON
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:

PHP
/**
 * @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:

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:

PHP
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.

Product, company and trademark names mentioned here belong to their respective owners and are used descriptively only. No affiliation with them, and no endorsement by them, is implied.