PHPStan Level 8: So kommst du hin, ohne die Feature-Arbeit zu stoppen
Warum die meisten Teams PHPStan Level 8 nie erreichen
Frag ein PHP-Team, ob es statische Analyse einsetzt, und die meisten sagen ja. Frag nach dem Level, und die ehrliche Antwort ist meistens 0 oder 1, einmal beim CI-Setup eingestellt und nie wieder angefasst. Der Grund ist vorhersehbar: Läuft PHPStan Level 8 zum ersten Mal gegen eine gewachsene Codebasis, tauchen typischerweise mehrere tausend Fehler auf, und niemand kann einen mehrwöchigen Aufräum-Sprint rechtfertigen, während die Roadmap brennt.
Die gute Nachricht: Das muss auch niemand. Die Migrationsstrategie unten stammt aus echten Kundenprojekten auf Symfony-Codebasen zwischen 40K und 700K Zeilen. Sie bringt Teams über einige Monate von Level 1 auf Level 8, während die Feature-Arbeit mit voller Geschwindigkeit weiterläuft, und sie verhindert den demoralisierendsten Fehlschlag überhaupt: einen Pull Request an Fehlern in Code scheitern zu lassen, den der PR nie angefasst hat.
Was dir jedes Level tatsächlich bringt
Die PHPStan-Levels bauen aufeinander auf und sind nicht gleich wertvoll. Zu wissen, wo das Signal steckt, hilft bei der Entscheidung, wie schnell du vorgehen solltest:
| Level | Was es hinzufügt | Rauschen vs. Wert |
|---|---|---|
| 0-2 | Unbekannte Klassen, Methoden, undefinierte Variablen, PHPDoc-Grundchecks | Fast reiner Wert, sofort beheben |
| 3-4 | Return-Typen, Property-Typen, Erkennung von totem Code | Hoher Wert, moderater Aufwand |
| 5-6 | Argument-Typprüfungen, fehlende Typehints (alles typisiert) | Der große Sprung: Level 6 macht meist die Hälfte aller Fehler aus |
| 7 | Teilweise falsche Union-Types | Hier verstecken sich echte Bugs, besonders bei nullable Returns |
| 8 | Methodenaufrufe auf nullable Typen | Das Level, das den klassischen "Null Pointer"-Produktionsvorfall abfängt |
Level 8 ist das Ziel, das sich in einem Engineering-OKR lohnt, weil es die häufigste Laufzeitfehlerquelle in PHP-Anwendungen eliminiert: den Methodenaufruf auf einem Wert, der null sein kann. Alles darüber (die mixed-Strenge von Level 9, Level 10 in neueren Releases) lohnt sich später, aber Level 8 ist der Punkt, an dem Produktionsvorfälle messbar zurückgehen.
Schritt 1: Die Baseline aufsetzen
Das Feature, das diese Migration ohne Lieferstopp möglich macht, ist die Baseline-Datei. Lass PHPStan auf deinem Ziel-Level laufen und schreibe jeden aktuellen Fehler in eine Ignore-Datei:
vendor/bin/phpstan analyse --level 8 --generate-baseline
Das erzeugt phpstan-baseline.neon, einen Katalog jedes bestehenden Fehlers mit Datei und Anzahl. Binde ihn in deine Konfiguration ein und der Effekt ist sofort da: CI ist heute auf Level 8 grün, bestehende Schulden sind eingefroren und dokumentiert, und jeder neue Code, der einen neuen Fehler einführt, lässt den Build fehlschlagen.
Das dreht die Ökonomie um. Statt "wir können Level 8 nicht aktivieren, bis wir 4.200 Fehler behoben haben" heisst es jetzt "ab heute kommen keine neuen Level-8-Fehler mehr in die Codebasis, und die 4.200 alten sind ein getrackter Backlog". Der Baseline-Zähler wird zur Burn-down-Metrik für ein Dashboard, und nach unserer Erfahrung ist er eine der wenigen Code-Qualitätsmetriken, denen Teams wirklich gern beim Fallen zusehen. Wenn du das mit einem breiteren Messansatz verbinden willst, sieh dir unseren Beitrag zu Code-Qualitätsmetriken, die zählen an.
Eine Regel hält die Baseline ehrlich: Sie darf nur schrumpfen. Die Baseline neu zu generieren, um neue Fehler zu verstecken, hebelt den ganzen Mechanismus aus. Behandle eine Baseline-Regeneration im PR-Diff deshalb als Review-Blocker, außer es ist eindeutig eine Löschung.
Schritt 2: Verzeichnis für Verzeichnis migrieren
Mit der Baseline als Schutz für neuen Code planst du das Aufräumen als Hintergrundarbeit statt als Sprint-stoppendes Projekt. Der Ansatz, der in der Praxis funktioniert, ist Ownership pro Verzeichnis:
- Wähle ein abgegrenztes Modul, zum Beispiel
src/Invoice/. - Behebe alle Baseline-Einträge für dieses Verzeichnis in einem fokussierten PR.
- Entferne die zugehörigen Zeilen aus der Baseline.
- Geh zum nächsten Verzeichnis.
Ein einzelnes Verzeichnis ist meist ein Tag Arbeit oder weniger, klein genug, um es zwischen Feature-Tickets zu schieben. Es hält auch das Review handhabbar: Ein PR, der Return-Typen in einem Modul ergänzt, ist leicht freizugeben, während ein Typ-Fix-PR über 4.000 Dateien unmöglich zu reviewen ist und liegen bleibt, bis er verrottet.
Priorisiere Verzeichnisse nach Incident-Historie, nicht alphabetisch. Das Modul, das jeden Monat jemanden aus dem Bett klingelt, ist das Modul, in dem Nullable-Fehler am ehesten echte Bugs sind statt kosmetischer Annotationen.
Schritt 3: Lass Rector die mechanische Arbeit machen
Ein großer Teil der PHPStan-Funde ist mechanisch: fehlende Return-Type-Deklarationen, fehlende Property-Typen, PHPDoc-Annotationen, die native Typen werden können. Das von Hand zu beheben ist Verschwendung von Senior-Engineering-Zeit. Rector automatisiert das meiste davon:
// rector.php
use Rector\Config\RectorConfig;
use Rector\TypeDeclaration\Rector\ClassMethod\ReturnTypeFromStrictNativeCallRector;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withPreparedSets(typeDeclarations: true)
->withTypeCoverageLevel(8);
Lass Rectors Type-Declaration-Set gegen das Verzeichnis laufen, das du als Nächstes aufräumst, prüfe den Diff und lass dann PHPStan erneut laufen. In Kundenprojekten löst das routinemäßig 60 bis 80 Prozent der Baseline-Einträge eines Moduls, bevor ein Mensch etwas anfasst. Die verbleibenden Fehler sind die interessanten: echte Logikfragen, ob ein Wert wirklich null sein kann, und die verdienen menschliche Aufmerksamkeit.
Der Dry-Run-Workflow ist wichtig für das Vertrauen. Führe immer zuerst vendor/bin/rector --dry-run aus, prüfe den vorgeschlagenen Diff im PR wie jede andere Änderung und halte Rector-Commits getrennt von manuellen Fixes, damit Reviewer den automatisierten Teil überfliegen und sich auf die Ermessensfragen konzentrieren können.
Die Symfony-spezifische Konfiguration, die das Rauschen reduziert
Vanilla-PHPStan versteht Symfonys Container, Doctrines Repositories oder Form-Types nicht, und diese Lücke erzeugt eine Wand aus False Positives, an der Teams aufgeben. Die Extension-Pakete schließen sie. Das ist die Startkonfiguration, die wir auf Symfony-7-Projekten ausliefern:
# phpstan.neon.dist
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
symfony:
containerXmlPath: var/cache/dev/App_KernelDevDebugContainer.xml
doctrine:
objectManagerLoader: tests/object-manager.php
Mit phpstan/phpstan-symfony und phpstan/phpstan-doctrine über den Extension-Installer löst PHPStan Container-Services auf, versteht, dass find() deine Entity oder null zurückgibt, und validiert DQL-Strings. Die Doctrine-Extension zahlt sich am schnellsten aus: Sie fängt Typabweichungen zwischen Entity-Properties und Spaltendefinitionen ab und markiert Repository-Methoden, deren PHPDoc Invoice[] verspricht, während die Query null zurückgeben kann. Diese beiden Kategorien machen einen überproportionalen Anteil der Produktionsbugs aus, die wir in Code-Audits finden.
Zwei eigene Ergänzungen lohnen sich auf den meisten Projekten: eine Regel, die @return-Annotationen auf eigenen Repository-Methoden verlangt (Doctrines magische Methoden machen untypisierte Repositories zu einem Nullability-Minenfeld), und treatPhpDocTypesAsCertain: false, wenn deine PHPDoc-Historie unzuverlässig ist, was auf fast jede Codebasis zutrifft, die älter als fünf Jahre ist.
PRs nicht blockieren: Prozessregeln, die es dauerhaft machen
Tooling allein bringt dich nicht auf Level 8. Die Teams, die ankommen, folgen ein paar Prozessregeln:
- CI prüft geänderte Dateien strikt, die Baseline deckt den Rest ab. Kein Entwickler sollte gezwungen sein, den zehn Jahre alten Typfehler eines Fremden zu beheben, um eine Einzeiler-Änderung auszuliefern.
- Der Baseline-Burn-down ist sichtbar. Poste den Zähler wöchentlich im Team-Channel. Fallende Zahlen erzeugen Momentum, unsichtbare Zahlen erzeugen Gleichgültigkeit.
- Pfadfinder-Fixes sind willkommen, aber begrenzt. Wer eine Datei anfasst, behebt ihre Baseline-Einträge nur, wenn es im reviewbaren Umfang des PRs bleibt. Sonst kommt es in den Verzeichnis-Durchgang.
- Level-Erhöhungen sind Ereignisse. Wenn die Baseline für das aktuelle Level null erreicht, erhöhe das Level, generiere die Baseline einmal neu und starte den nächsten Burn-down. Jede Erhöhung ist ein legitimer Grund zum Feiern.
Auf einer Legacy-Codebasis passt das natürlich zu einer breiteren Modernisierung. Statische Analyse ist genau dann am wertvollsten, wenn du alten Code änderst, weil sie dir sagt, was der Code wirklich tut statt was seine Kommentare behaupten. Wenn du auf ein System aus der Symfony-2-Ära starrst, zeigt unser Schritt-für-Schritt-Guide zum Legacy-PHP-Refactoring, wo PHPStan in die größere Migrationssequenz gehört, und unser Service zur Legacy-Code-Optimierung deckt den Weg mit externer Hilfe ab.
Was dich erwartet: eine realistische Timeline
Für eine 100K-Zeilen-Symfony-Anwendung mit einem Fünf-Personen-Team, das Vollzeit Features ausliefert, sehen wir ein konsistentes Muster: Woche eins richtet PHPStan mit Extensions, der Level-8-Baseline und CI-Enforcement ein. Die Monate eins bis drei brennen die Baseline Verzeichnis für Verzeichnis ab, wobei Rector die mechanische Mehrheit erledigt. Irgendwann in Monat drei oder vier erreicht die Baseline null und das Team steht sauber auf Level 8, ohne die Roadmap je eingefroren zu haben.
Der Nutzen zeigt sich schon vor Ende der Migration. Teams berichten typischerweise innerhalb von Wochen vom ersten verhinderten Produktionsvorfall, meist ein nullable Return, der vorher ein 500er für einen Kunden geworden wäre.
Hol dir zuerst eine externe Einschätzung
Wenn deine Baseline-Generierung eine fünfstellige Fehlerzahl liefert und du unsicher bist, ob die Codebasis die Investition wert ist, verdient diese Frage eine strukturierte Antwort, bevor du Monate an Hintergrundarbeit investierst. Ein fokussiertes Code-Qualitäts-Audit liefert dir die Fehler-Taxonomie, die Module, in denen Typfehler mit Incidents korrelieren, und einen priorisierten Migrationsplan.
Schreib an hello@wolf-tech.io oder informier dich auf wolf-tech.io, und wir sagen dir ehrlich, ob Level 8 eine dreimonatige Hintergrundaufgabe ist oder das Symptom eines tieferen Modernisierungsbedarfs.

