Alle Artikel
12. September 2026
9 mins

PHPStan Stufe für Stufe in Altcode einführen

Von Tim Rutte, Cloud & Software ArchitectThemaLegacy & Modernisierung

Ein hoher Stapel weißen Papiers, über den ein blaues Lesebändchen läuft.

Statische Analyse in einem gewachsenen PHP-Projekt hat einen bekannten ersten Moment: Man lässt PHPStan laufen, bekommt achttausend Fehler und schaltet es wieder aus. Der Eindruck, den das hinterlässt, ist falsch, und er kostet ein wirksames Werkzeug.

Die achttausend Meldungen sind kein Urteil über den Code. Sie sind das Ergebnis davon, dass fünfzehn Jahre Code auf einmal gegen Regeln geprüft werden, die es beim Schreiben noch nicht gab. Genau dafür gibt es einen Mechanismus, der aus dieser Zahl eine Null macht, ohne eine einzige Zeile zu ändern.

Dieser Artikel beschreibt, wie man PHPStan in Altcode einführt, was auf welcher Stufe wirklich gefunden wird, und wo der Punkt liegt, an dem weitere Stufen nichts mehr bringen.

Warum es in Altcode anders läuft

In einem neuen Projekt ist statische Analyse ein Qualitätswerkzeug: Sie verhindert Fehler, bevor sie entstehen. In Altcode ist sie zunächst etwas anderes, nämlich ein Messgerät.

Sie beantwortet drei Fragen, die vor jeder Modernisierung nützlich sind, und zwar ohne dass jemand den Code liest.

Wo ist der Code unsicher, nicht nur hässlich? Ein Aufruf auf einem Wert, der null sein kann, ist ein möglicher Ausfall. Eine lange Methode ist nur eine lange Methode.

Welche Stellen sind so dynamisch, dass sie niemand analysieren kann? Zusammengesetzte Klassennamen, magische Methoden, Arrays als Objekte. Das sind die Stellen, an denen auch ein Werkzeug wie Rector vorsichtig sein muss, und die Liste ist eine gute Näherung für „hier wird es teuer".

Wie verteilt sich das über das Projekt? Zweitausend Meldungen in einem Modul und dreißig im Rest sagen mehr über die Reihenfolge der Arbeit als jede Schätzung.

Für alle drei Fragen muss keine einzige Meldung behoben werden. Das ist der Punkt, der den Einstieg leicht macht.

Die Baseline: der Befehl, der aus achttausend null macht

PHPStan kann den gefundenen Zustand einfrieren. Alles, was heute da ist, gilt als bekannt; ab morgen zählen nur neue Meldungen.

composer require --dev phpstan/phpstan

# Erst messen, ohne etwas festzuschreiben
vendor/bin/phpstan analyse src --level=0

# Dann einfrieren: erzeugt phpstan-baseline.neon
vendor/bin/phpstan analyse --generate-baseline
# phpstan.neon
includes:
    - phpstan-baseline.neon

parameters:
    level: 0
    paths:
        - src
    # Der Bestand ist eingefroren, neue Fehler brechen den Build.
    # Die Datei schrumpft mit der Zeit, sie waechst nie.

Ab hier ist das Werkzeug nutzbar, und zwar am selben Tag. Der Bestand steht in einer Datei, jede neue Zeile Code wird geprüft, und niemand musste vorher achttausend Meldungen abarbeiten.

Zwei Regeln halten das gesund, und beide sind organisatorisch, nicht technisch.

Die Baseline wächst nie. Wenn jemand sie neu erzeugt, weil ein Build rot ist, ist der ganze Mechanismus wertlos. Das lässt sich prüfen: Die Zeilenzahl der Datei darf in einem Änderungssatz kleiner werden, nie größer.

Wer eine Datei anfasst, räumt ihre Einträge auf. Nicht das ganze Projekt, die Datei, an der man ohnehin gerade arbeitet. Damit schrumpft die Baseline dort, wo gearbeitet wird, und genau dort ist sie auch wichtig.

Stufe 0 bis 2: die Fehler, die echte Fehler sind

Die unteren Stufen sind die, die sich auch in Altcode fast immer lohnen, weil sie kaum Falschmeldungen erzeugen. Was dort gefunden wird, ist mit wenigen Ausnahmen ein Fehler.

Stufe 0 findet Aufrufe auf Klassen und Methoden, die es nicht gibt. Das klingt harmlos und ist der häufigste Fund mit echtem Schaden: eine Methode, die in einem Zweig aufgerufen wird, den seit Jahren niemand erreicht, und die beim Umbenennen vor vier Jahren übersehen wurde.

Stufe 1 ergänzt unbekannte Variablen. Das findet Tippfehler in selten benutzten Zweigen, und es findet Variablen, die in einer Schleife gesetzt und danach benutzt werden, obwohl die Schleife leer sein kann.

Stufe 2 prüft die Aufrufe auf Typen, soweit sie bekannt sind. Hier tauchen die ersten echten Kandidaten für Ausfälle auf: eine Methode, die auf einem Wert aufgerufen wird, der auch false sein kann, weil die aufgerufene Funktion im Fehlerfall false liefert.

// Stufe 2 meldet das, und zu Recht:
// simplexml_load_string() liefert im Fehlerfall false.
$xml = simplexml_load_string($antwort);
foreach ($xml->position as $p) {   // Aufruf auf false, wenn XML kaputt
    ...
}

// Behoben, ohne die Logik zu aendern:
$xml = simplexml_load_string($antwort);
if ($xml === false) {
    throw new SchnittstellenFehler('Antwort ist kein gueltiges XML');
}

Diese drei Stufen sind in den meisten Projekten in wenigen Tagen sauber, wenn man die Baseline dafür abarbeitet. Ab Stufe 3 ändert sich der Charakter der Arbeit.

Stufe 3 bis 5: die Typen, die nicht da sind

Ab hier geht es nicht mehr um Fehler, sondern um fehlende Angaben. PHPStan meldet, dass es etwas nicht wissen kann, und die Behebung besteht darin, es aufzuschreiben.

Der größte Block sind Arrays. In altem PHP ist ein Array alles: Liste, Wörterbuch, Datensatz, Konfiguration. PHPStan verlangt ab Stufe 4, zu sagen, was drin ist.

/**
 * Die Angabe ist keine Zierde: sie macht aus einem "array" eine
 * Zusage, die an jeder Aufrufstelle geprueft wird.
 *
 * @param list<array{id: int, betrag: int, waehrung: string}> $positionen
 * @return array<string, int>  Summe je Waehrung, in Cent
 */
public function summiere(array $positionen): array
{
    ...
}

Der Ertrag dieser Arbeit ist größer, als er aussieht, und er zeigt sich nicht in gefundenen Fehlern. Er zeigt sich, wenn jemand den Code sechs Monate später liest: Die Frage „was steht eigentlich in diesem Array" ist beantwortet, ohne dass man den Aufrufer suchen muss.

Die Reihenfolge, die sich lohnt, ist auch hier nicht „alles". Es sind die Stellen, an denen Daten das Modul verlassen: öffentliche Methoden, Rückgaben an Aufrufer, Schnittstellen. Innerhalb einer Methode bringt die Angabe wenig.

Wo es sich nicht mehr lohnt

Die Stufen gehen bis 10, und es gibt keinen Grund, dort anzukommen. Für Altcode liegt der sinnvolle Endpunkt in den meisten Projekten bei Stufe 5 oder 6.

Der Grund ist das Verhältnis von Aufwand zu Fund. Ab Stufe 6 besteht die Arbeit überwiegend darin, Angaben für Code zu schreiben, der sich nicht mehr ändert, und Dinge zu erklären, die PHPStan aus Prinzip nicht wissen kann. Der Nutzen davon ist gering, der Aufwand nicht.

Eine Ausnahme lohnt und wird oft übersehen: Neuer Code kann eine höhere Stufe haben als alter. Das lässt sich in derselben Konfiguration ausdrücken und ist der beste Kompromiss, den ich kenne.

parameters:
    level: 5
    paths:
        - src
    # Alles, was seit der Modernisierung entsteht, wird strenger geprueft.
    # Der Altcode bleibt auf 5, ohne dass das Neue darunter leidet.
    strictRules:
        allRules: false

# phpstan-neu.neon: eigener Lauf fuer die neuen Module
includes:
    - phpstan.neon
parameters:
    level: 8
    paths:
        - src/Abrechnung
        - src/Versand

Was man ignoriert, und warum das aufgeschrieben wird

In jedem Altprojekt gibt es Meldungen, die richtig sind und die man nicht beheben wird. Das ist in Ordnung, solange es eine Entscheidung ist und keine Gewohnheit.

Der Unterschied zwischen beidem ist ein Kommentar an der richtigen Stelle.

parameters:
    ignoreErrors:
        # Der Alt-Container liefert mixed. Ein Umbau auf typisierte
        # Aufloesung ist geplant (siehe docs/entscheidungen/2026-10-30),
        # bis dahin waeren es 400 Meldungen ohne Erkenntnisgewinn.
        - message: '#Cannot call method .* on mixed#'
          path: src/Legacy/Container.php

        # Absichtlich dynamisch: die Plugin-Namen stehen in der Datenbank.
        # Das ist der Kern des Erweiterungsmechanismus und bleibt so.
        - message: '#Class .* not found#'
          path: src/Plugin/Lader.php

Beide Einträge sind legitim, und beide sagen, warum. Ein ignoreErrors ohne Begründung ist in zwei Jahren nicht von Bequemlichkeit zu unterscheiden, und dann traut sich niemand, es zu entfernen. Das ist dasselbe Muster wie bei jeder unterdrückten Warnung.

Was nicht ignoriert wird: alles in Dateien, an denen gerade gearbeitet wird. Dort ist eine Meldung billig zu beheben, und der Anlass ist ohnehin da.

Das Gegenstück, das nicht findet, sondern ändert, steht in Rector im Altprojekt.

In die Pipeline, ohne sie anzuhalten

Der letzte Schritt ist der, der aus einem Werkzeug eine Gewohnheit macht. Er ist klein, und er hat eine Bedingung.

# Der Lauf ist schnell genug fuer jeden Zweig, wenn der Cache steht.
vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=github

Die Bedingung: Der Lauf darf nicht länger dauern als das, was ein Entwickler zu warten bereit ist. Bei einem großen Projekt heißt das, den Ergebniscache zwischen Läufen zu behalten. Ohne den dauert die Analyse Minuten, und dann wird sie umgangen.

Und die zweite Bedingung, die wichtiger ist als die erste: Beim ersten Rot muss klar sein, was zu tun ist. Eine Meldung, die auf eine Zeile zeigt, die jemand gerade geschrieben hat, ist hilfreich. Eine, die auf eine Datei zeigt, die seit 2015 unverändert ist, ist ein Fehler in der Baseline, und sie kostet Vertrauen in das ganze Werkzeug.

Was eine statische Prüfung grundsätzlich nicht sieht, deckt nur ein Test ab: Legacy-Code testen, wenn es keine Tests gibt.

Was PHPStan nicht findet

Damit die Erwartung stimmt, zum Schluss die Grenze. Statische Analyse prüft, ob der Code in sich stimmig ist, nicht ob er das Richtige tut.

Falsche Logik bleibt unsichtbar. Eine Rabattberechnung, die den falschen Prozentsatz nimmt, ist typsicher. Dafür gibt es Tests, und die sind die andere Hälfte des Netzes.

Alles, was erst zur Laufzeit entsteht. Ein Klassenname aus der Datenbank, eine Konfiguration aus einer Datei, ein Array aus einer Schnittstelle. Genau dort liegen in Altsystemen viele Fehler, und genau dort schweigt die Analyse.

Der Zustand der Daten. Dass eine Spalte in der Datenbank NULL enthält, obwohl der Code das nicht erwartet, sieht kein Analysewerkzeug. Das findet nur ein Lauf über echte Daten.

Deshalb ist die Reihenfolge in einer Modernisierung meist: erst ein Netz aus Tests um die Stellen, die geändert werden, dann statische Analyse für alles andere. Die Analyse ist billiger und breiter, die Tests sind genauer und teurer, und keines von beidem ersetzt das andere.

Wie ein PHP-Upgrade abläuft, steht auf einer eigenen Seite; eine eingeführte Analyse ist dort einer der Gründe, warum sich der Aufwand vorher beziffern lässt.

Dieser Artikel gehört zu einer Reihe über Systeme, die es schon gibt. Der Rückblick ordnet alle Artikel der Reihe nach Anlass.