Zum Inhalt springen

PHPStan in ein bestehendes Projekt einführen

Veröffentlicht am

PHPStan findet Fehler, klar. In einem Projekt, das seit Jahren wächst, ist mir etwas anderes wichtiger: Ich kann damit Regeln festlegen und durchsetzen. Was in der Pipeline rot wird, kommt nicht mehr rein. Egal, ob es ein Kollege am Freitagabend geschrieben hat oder ein Assistent, der zu schnell war.

Die alten Fehler wirst du nicht in einem Rutsch los. Du baust aber ab dem ersten Tag keine neuen mehr ein, und die alten werden nebenher weniger, Datei für Datei.

Alles hier stammt aus Projekten, in denen es so läuft: PHPStan 2.1 auf PHP 8.4, Level 8. Das älteste hat rund 3.100 Einträge in der Baseline, das jüngste gut 180.

Die PHPStan-Konfiguration: Baseline einbinden, Level 8, Pfad src

Der Einstieg: Level und Baseline

PHPStan kennt die Level 0 bis 10. Je höher, desto mehr prüft es. Level 0 findet unbekannte Klassen und Methoden, ab Level 5 werden Argumenttypen verglichen, und Level 8 verbietet den Zugriff auf etwas, das null sein könnte. Auf einem alten Projekt heißt das beim ersten Lauf: Tausende Fehler.

Die Konfiguration ist klein:

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

Dazu kommt ein Befehl, der den Ist-Zustand als „bekannt“ festhält:

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

Heraus kommt phpstan-baseline.neon, eine Liste von Fehlern, die PHPStan ab jetzt überspringt:

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

Ab da ist phpstan analyse grün. Rot wird es nur noch, wenn etwas Neues dazukommt.

Ein Eintrag besteht aus Meldung, Datei und Anzahl. Eine Zeilennummer steht nicht drin. Wenn du weiter oben in der Datei zehn Zeilen einfügst, verschiebt sich also nichts. Baust du dagegen in einer Datei mit zwei bekannten Fehlern denselben Fehler ein drittes Mal ein, ist die Anzahl überschritten und PHPStan meldet ihn.

Die Baseline schrumpft von allein

Das gefällt mir daran am besten. Wenn du einen Fehler behebst, der in der Baseline steht, beschwert sich PHPStan:

Ignored error pattern #^Argument of an invalid type array\|false …$# in path
src/Legacy/ReportExport.php was not matched in reported errors.

Der Eintrag ist übrig und muss raus. Statt ihn von Hand in der Datei zu suchen, kannst du die Baseline neu erzeugen und den Diff lesen. Dort dürfen nur Zeilen verschwunden oder ein count gesunken sein. Steht etwas Neues drin, hast du gerade einen Fehler durchgewunken. Ein Commit, der eine Klasse aufräumt, besteht damit immer aus dem Code und der kürzeren Baseline. Im Review sieht man beides, und die Zahl soll nur kleiner werden.

Zwei Dinge solltest du wissen, bevor du dich darauf verlässt.

--generate-baseline winkt alles durch. Ein Aufruf, und jeder neue Fehler steht mit in der Datei. Die Zeilenzahl (git diff --numstat) hilft dagegen nicht: Fliegt ein Eintrag raus und kommt dafür ein neuer dazu, bleibt die Datei gleich lang. Verlässlich ist nur der Vergleich Eintrag für Eintrag. Die Pipeline liest die Baseline aus dem Zielbranch und aus dem Merge Request und schlägt an, sobald ein Paar aus Meldung und Datei neu ist oder sein count gestiegen ist.

Große Umbauten erzeugen außerdem Rauschen. Benennst du eine Methode um, ändert sich die Meldung in jedem Eintrag, der sie erwähnt. Solche Commits macht man am besten einzeln.

Strenger als Level 8: Erweiterungen

Auch Level 10 prüft nicht alles, was ich mir wünschen würde. Dafür gibt es Erweiterungen, die per includes dazukommen. Bei mir laufen diese:

Paket Was es zusätzlich verlangt
phpstan/phpstan-strict-rules Strikte Vergleiche, keine Ausdrücke in Bedingungen, die nicht bool sind
ergebnis/phpstan-rules declare(strict_types=1) in jeder Datei, final für Klassen, keine null-Defaults
phpstan/phpstan-deprecation-rules Meldet jeden Aufruf von etwas, das als @deprecated markiert ist
larastan/larastan Kennt Laravel: Eloquent-Modelle, Facades, Collections

Eingebunden wird jedes Paket mit einer Zeile:

NEON
includes:
    - phpstan-baseline.neon
    - vendor/phpstan/phpstan-strict-rules/rules.neon
    - vendor/ergebnis/phpstan-rules/rules.neon

Auf einem alten Projekt bringt jedes davon neue Fehler mit. Genau dafür ist die Baseline gut. Du schaltest die Regeln ein, frierst den Stand ab, und ab dem nächsten Commit gelten sie für alles, was neu geschrieben wird. Passt eine Regel nicht zum Projekt, lässt sie sich einzeln abschalten:

NEON
parameters:
    ergebnis:
        noExtends:
            enabled: false

Eine eigene Regel in 30 Zeilen

Irgendwann willst du etwas prüfen, das es nicht fertig gibt. Bei mir war das: Klassenkonstanten brauchen einen Typ. Seit PHP 8.3 geht const int MAX = 5;, und wenn das einmal eingeführt ist, soll es auch so bleiben.

Eine Regel ist eine Klasse mit zwei Methoden. getNodeType() sagt, für welche Art von Syntaxknoten sie zuständig ist. processNode() bekommt jeden solchen Knoten und gibt eine Liste von Fehlern zurück:

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('Klassen-Konstanten müssen einen expliziten Typen haben.')
                ->identifier('classConstant.missingType')
                ->build(),
        ];
    }
}

Registriert wird sie in der phpstan.neon:

NEON
rules:
    - App\PHPStan\Rules\RequireClassConstantTypeRule

Ab jetzt gilt die Regel für den ganzen Code, und die Baseline nimmt auf, was schon dagegen verstößt. Den identifier solltest du immer setzen. Über ihn lässt sich der Fehler später gezielt ignorieren, und er steht in der Baseline neben der Meldung.

Wenn du nicht weißt, wie du an einen bestimmten Knoten kommst, hilft ein Blick in den Syntaxbaum. Die Knotentypen stammen aus nikic/php-parser und heißen so wie in der Grammatik (ClassConst, MethodCall, New_). Dazu gibt es Scope. Der weiß, in welcher Klasse und Methode du gerade bist und welchen Typ ein Ausdruck hat.

Gegenprobe

Eine Regel, die nie rot wird, fällt nicht auf. Deshalb bekommt jede neue Regel einmal eine Wegwerf-Datei, die gegen sie verstößt:

PHP
final class Demo
{
    public const MAX = 5;
}

Wird phpstan analyse rot und ist die Meldung verständlich, stimmt die Regel. Danach kommt die Datei wieder weg. Wenn du die Regel dauerhaft absichern willst, schreib daraus einen Test mit RuleTestCase, das PHPStan mitliefert.

Was PHPStan nicht kann

PHPStan prüft, was in einer Datei steht. Wo die Datei liegt, wie sie heißt und wer wen benutzen darf, prüft es nicht. Dafür nehme ich PHPArkitect. Es arbeitet mit demselben Baseline-Prinzip. PHPStan kümmert sich also um den Inhalt, PHPArkitect um die Struktur.

Genannte Produkt-, Firmen- und Markennamen gehören ihren jeweiligen Inhabern und werden hier allein beschreibend verwendet. Eine Verbindung zu ihnen oder eine Empfehlung durch sie besteht nicht.