von Level 0 bis zur maximalen Strictness
PHPStan Level max deckt Fehlerklassen auf, die Unit-Tests oft übersehen: falsche Argumenttypen, riskante Null-Zugriffe, unerreichbaren Code. Ein wachsendes Projekt erreicht dieses Level jedoch nicht über Nacht, sondern über Level-Ratcheting, eine gepflegte Baseline-Datei, sauber typisierte Generics und eigene Regeln für projektspezifische Konventionen.
Inhaltsverzeichnis
- 1. Was PHPStan Level max wirklich bedeutet
- 2. Level-Ratcheting: Schrittweise von Level 0 zur Maximalstufe
- 3. Baseline-Dateien: Bestandscode einfrieren, ohne ihn zu ignorieren
- 4. Generics und Templates: @template, @extends und PHPDoc-Generics
- 5. Array Shapes: Präzise Typen für assoziative Arrays
- 6. Strictness-Konfiguration: Die Stellschrauben der Analyse
- 7. Eigene Regeln schreiben: Das Rule-Interface implementieren
- 8. CI-Integration: Strikte Analyse in der Pipeline durchsetzen
- 9. PHPStan-Level im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Was PHPStan Level max wirklich bedeutet
PHPStan kennt Rule-Level von 0 bis 9, jedes höhere Level aktiviert zusätzliche, strengere Prüfungen auf denselben Code. Der Alias level: max in der Konfiguration steht nicht für eine feste Zahl, sondern für das jeweils höchste verfügbare Level einer PHPStan-Version, aktuell identisch mit Level 9, aber bei jedem Major-Release potenziell um neue Prüfungen erweitert. Wer PHPStan Level max in phpstan.neon einträgt, bindet sich also nicht an einen statischen Zustand, sondern an die kontinuierlich wachsende Menge dessen, was das Tool an Typfehlern, unmöglichen Vergleichen und riskanten Zugriffen erkennen kann.
Der praktische Wert davon liegt darin, dieselbe Klasse von Fehlern zu finden, die sonst erst zur Laufzeit oder in einem Code-Review auffällt: Zugriffe auf möglicherweise null, Methodenaufrufe auf Objekten mit falschem Typ, unerreichbarer Code nach einem return, oder Rückgabewerte, die nicht zur deklarierten Signatur passen. Anders als ein Unit-Test prüft PHPStan diese Fälle statisch, ohne den Code auszuführen, und deckt dadurch auch Pfade ab, die kein Test jemals durchläuft. Das gilt unabhängig davon, ob das Projekt ein Framework wie Symfony einsetzt, eine eigene Micro-Architektur nutzt oder, wie in vielen Agentur-Setups, auf einer Plattform wie Magento aufsetzt, PHPStan analysiert reinen PHP-Code, unabhängig vom darüberliegenden Ökosystem.
Der verbreitetste Fehler beim Einstieg ist, die höchste Stufe direkt in ein bestehendes, gewachsenes Projekt einzutragen. Das Ergebnis sind typischerweise mehrere tausend gemeldete Fehler auf einen Schlag, eine Liste, die niemand ernsthaft durcharbeitet, weshalb die Analyse in der Praxis meist ignoriert oder aus der CI-Pipeline wieder entfernt wird. Die folgenden Abschnitte beschreiben, wie ein Projekt die Zielstufe stattdessen schrittweise und ohne Produktivitätsverlust erreicht.
2. Level-Ratcheting: Schrittweise von Level 0 zu PHPStan Level max
Level-Ratcheting bezeichnet die Strategie, das PHPStan-Level nicht in einem Schritt, sondern über mehrere kleine, geplante Erhöhungen anzuheben, jede davon als eigener Commit oder Pull Request. Man beginnt mit einem niedrigen Level, typischerweise 0 oder 1, das nur grundlegende Fehler wie unbekannte Klassen oder Funktionen meldet, behebt die gefundenen Probleme, hebt das Level um eine Stufe an und wiederholt den Vorgang. Der Begriff Ratchet (Sperrklinke) beschreibt treffend das Prinzip: Das Level darf sich nur in eine Richtung bewegen, nach oben, niemals unbeabsichtigt zurückfallen.
# phpstan.neon
parameters:
level: 6
paths:
- src
- tests
excludePaths:
- src/Legacy/*
tmpDir: var/cache/phpstan
includes:
- phpstan-baseline.neon
Jede Anhebung des level-Werts macht sofort sichtbar, wie viele neue Fehlermeldungen die nächste Stufe verursacht, meist deutlich weniger als der Sprung direkt auf die Maximalstufe gewesen wäre. Ein Team kann diese Anzahl gegen die verfügbare Kapazität abwägen: Sind es zwanzig neue Meldungen, lassen sie sich in derselben Woche beheben, sind es zweihundert, wandern die unkritischen Fälle zunächst in die Baseline-Datei, während echte Bugs sofort korrigiert werden. So bleibt das Team stets auf einem validen, grün laufenden Zwischenstand, statt wochenlang an einer Großbaustelle zu arbeiten.
Ein produktives Muster ist, das Ziel-Level als eigenen CI-Check zu etikettieren und pro Quartal oder Sprint eine feste Anhebung einzuplanen, etwa von Level 5 auf 6, dann auf 7, bis die Zielstufe erreicht ist. Für neue Module oder frisch geschriebenen Code lohnt sich zusätzlich eine per-Pfad-Konfiguration mit höherem Level als der Rest des Projekts, damit neuer Code von Anfang an dem strengeren Standard folgt, während Legacy-Bereiche schrittweise nachziehen.
3. Baseline-Dateien: Bestandscode einfrieren, ohne ihn zu ignorieren
Eine phpstan-baseline.neon, erzeugt mit vendor/bin/phpstan analyse --generate-baseline, friert alle zum Erzeugungszeitpunkt bestehenden Fehlermeldungen als konkrete ignoreErrors-Einträge ein, jeweils mit Regex-Pattern, Dateipfad und erwarteter Anzahl. Der entscheidende Unterschied zu einem pauschalen ignoreErrors-Eintrag ohne Baseline: Jeder eingefrorene Fehler ist exakt dokumentiert, an welcher Datei und in welcher Häufigkeit er auftritt. Ändert sich die Anzahl, etwa weil ein neuer, gleichartiger Fehler hinzukommt, schlägt die Analyse fehl, statt den neuen Fehler stillschweigend mit zu verschlucken.
# phpstan-baseline.neon
# Generated with: vendor/bin/phpstan analyse --generate-baseline
parameters:
ignoreErrors:
-
message: '#^Call to an undefined method App\\Legacy\\OrderExporter::mapStatus\(\)\.$#'
count: 3
path: src/Legacy/OrderExporter.php
-
message: '#^Method App\\Repository\\InvoiceRepository::findOpen\(\) return type has no value type specified in iterable type array\.$#'
count: 1
path: src/Repository/InvoiceRepository.php
Die Baseline ermöglicht es, PHPStan Level max sofort für neuen Code zu erzwingen, während der bestehende Bestandscode nicht in einem einzigen Kraftakt umgeschrieben werden muss. Neue Fehler in neuem oder geändertem Code fallen sofort auf, weil sie nicht in der Baseline stehen, während bekannte Altlasten dokumentiert, aber nicht blockierend bleiben. Wichtig ist, die Baseline als technische Schuld zu behandeln, die sichtbar und messbar ist, nicht als dauerhaften Freibrief.
In der Praxis bewährt es sich, die Baseline regelmäßig neu zu generieren und dabei den Diff im Pull Request zu reviewen: Schrumpft die Datei, wurden echte Fehler behoben, wächst sie unerwartet, hat jemand neuen Code ohne ausreichende Typisierung eingecheckt. Bei großen Projekten lohnt sich eine Aufteilung in mehrere Baseline-Dateien pro Modul, eingebunden über separate includes-Einträge, damit ein Team gezielt an der Baseline seines eigenen Verantwortungsbereichs arbeiten kann, ohne die eines anderen Teams zu berühren.
4. Generics und Templates: @template, @extends und PHPDoc-Generics
Plain PHP kennt bis heute keine nativen Generics auf Sprachebene, PHPStan kompensiert diese Lücke jedoch vollständig über PHPDoc-Annotationen wie @template, @extends und @implements. Eine generische Sammlung deklariert einen Platzhalter-Typ T über @template T auf Klassenebene, referenziert ihn in Property- und Methoden-Docblocks, und PHPStan verfolgt diesen Typ durch die gesamte Aufrufkette, exakt wie ein echtes generisches Typsystem.
<?php
declare(strict_types=1);
namespace App\Collection;
/**
* Generic, type-safe collection of homogeneous items.
*
* @template T
*/
class TypedCollection implements \Countable, \IteratorAggregate
{
/** @var list<T> */
private array $items = [];
/**
* @param class-string<T> $itemClass Fully qualified class name items must be instances of
*/
public function __construct(private readonly string $itemClass)
{
}
/**
* @param T $item
*/
public function add(object $item): void
{
if (!$item instanceof $this->itemClass) {
throw new \InvalidArgumentException('Unexpected item type');
}
$this->items[] = $item;
}
/**
* @return T|null
*/
public function first(): ?object
{
return $this->items[0] ?? null;
}
public function count(): int
{
return count($this->items);
}
/**
* @return \ArrayIterator<int, T>
*/
public function getIterator(): \ArrayIterator
{
return new \ArrayIterator($this->items);
}
}
/**
* @extends TypedCollection<Invoice>
*/
final class InvoiceCollection extends TypedCollection
{
public function __construct()
{
parent::__construct(Invoice::class);
}
}
Die Annotation @extends TypedCollection<Invoice> bindet T in InvoiceCollection konkret an Invoice, wodurch first() für diese Unterklasse als ?Invoice statt als generisches ?object erkannt wird. Auf der höchsten PHPStan-Stufe wird jeder Aufruf, der eine falsche Klasse in add() übergibt oder das Ergebnis von first() ohne Null-Check als konkretes Objekt behandelt, sofort gemeldet, ein Sicherheitsnetz, das ohne native Generics in PHP sonst gar nicht existieren würde.
Für Kovarianz eignet sich @template-covariant T, wenn ein generischer Typ nur gelesen, nie geschrieben wird, das erlaubt PHPStan, ReadOnlyCollection<Invoice> dort zu akzeptieren, wo ReadOnlyCollection<BillableItem> erwartet wird, sofern Invoice BillableItem implementiert. Moderne IDEs lesen dieselben Annotationen und bieten dadurch korrekte Autovervollständigung für generische Rückgabewerte, ein zusätzlicher Produktivitätsgewinn, der weit über die reine Fehlererkennung hinausgeht.
5. Array Shapes: Präzise Typen für assoziative Arrays
Assoziative Arrays sind in PHP allgegenwärtig, aber ohne zusätzliche Typinformation für PHPStan praktisch unsichtbar: ein einfaches array im Rückgabetyp sagt nichts darüber aus, welche Keys vorhanden sind oder welchen Typ die Werte haben. Array Shapes schließen diese Lücke mit einer PHPDoc-Syntax wie array{name: string, age: int, email?: string}, die exakt beschreibt, welche Keys ein Array besitzt, welchen Typ jeder Wert hat und welche Keys optional sind, gekennzeichnet durch das ? nach dem Key-Namen.
Ergänzend existieren list<T> für sequenzielle Arrays mit garantiert lückenlosen Integer-Keys ab 0, und non-empty-array<T> für Arrays, die PHPStan als garantiert nicht leer behandelt, etwa nach einer expliziten Prüfung mit count($array) > 0. Diese Präzision zahlt sich vor allem auf der strengsten Analyse-Stufe aus, wo generische array-Typen ohne Value-Typ-Angabe ohnehin als Fehler gemeldet werden: Statt jede Funktion mit unspezifischem array-Parameter aufzuweichen, beschreibt ein Array Shape exakt die erwartete Struktur.
Der praktische Nutzen zeigt sich besonders bei Konfigurationsarrays und API-Antwortstrukturen: Ein Tippfehler im Key-Namen, etwa $config['databse'] statt $config['database'], wird sofort erkannt, wenn die Funktion einen Array Shape mit dem korrekten Key erwartet, ein Fehler, der zur Laufzeit sonst erst durch eine fehlende oder null-Rückgabe auffallen würde. Die Migration erfolgt schrittweise: Man beginnt mit den am häufigsten aufgerufenen Funktionssignaturen und ergänzt Array Shapes dort, wo der generische array-Typ bislang echte Typinformation verschleiert.
6. Strictness-Konfiguration: Die Stellschrauben von PHPStan Level max
Die höchste PHPStan-Stufe aktiviert zwar die meisten strengen Prüfungen automatisch, einige Verhaltensweisen bleiben aber über separate Boolean-Parameter in phpstan.neon einstellbar. checkMissingIterableValueType erzwingt, dass jedes iterable und array einen Value-Typ trägt, etwa array<int, Invoice> statt nacktem array, standardmäßig ab mittleren Levels aktiv, aber explizit erzwingbar, falls ein Projekt es früher durchsetzen will. checkGenericClassInNonGenericObjectType meldet, wenn eine generische Klasse ohne Typparameter referenziert wird, etwa Collection statt Collection<Invoice>, und deckt damit unvollständig migrierte Generics-Annotationen auf.
treatPhpDocTypesAsCertain steuert, ob PHPStan PHPDoc-Typangaben als absolut verlässlich behandelt (Standard: true) oder als unsichere Zusatzinformation, die zur Laufzeit abweichen könnte. Auf false gesetzt, verlangt PHPStan zusätzliche Laufzeitprüfungen, bevor es sich auf einen PHPDoc-Typ verlässt, sinnvoll in Projekten mit historisch unzuverlässigen oder veralteten Docblocks, aber auch strenger und aufwendiger in der Migration. Das Paket phpstan-strict-rules ergänzt die eingebauten Regeln um zusätzliche, nicht im Kern enthaltene Prüfungen, etwa ein Verbot impliziter Bool-zu-String-Konvertierungen oder eine Pflicht zu expliziten declare(strict_types=1)-Deklarationen in jeder Datei.
Weitere relevante Knobs sind checkUninitializedProperties, das typisierte Properties ohne Default-Wert und ohne garantierte Initialisierung im Konstruktor meldet, sowie checkBenevolentUnionTypes, das auf implizite, von PHP selbst tolerierte Typ-Koerzionen aufmerksam macht, etwa einen int, der stillschweigend als string weiterverwendet wird. Jeder dieser Parameter lässt sich einzeln vor dem Erreichen der Maximalstufe aktivieren, um die Migration in kleinere, unabhängig überprüfbare Schritte zu zerlegen, statt alle Verschärfungen gleichzeitig einzuführen.
7. Eigene Regeln schreiben: Das Rule-Interface implementieren
Manche projektspezifischen Konventionen lassen sich mit den mitgelieferten Regeln von PHPStan nicht abbilden, etwa das Verbot bestimmter Funktionsaufrufe, eine Pflicht zu final-Klassen in bestimmten Namespaces oder die Durchsetzung eines internen Naming-Schemas. Für solche Fälle implementiert man das Interface PHPStan\Rules\Rule, das genau zwei Methoden verlangt: getNodeType() gibt zurück, für welchen Node-Typ des zugrunde liegenden AST (bereitgestellt von nikic/php-parser) die Regel aufgerufen wird, processNode() erhält den konkreten Node und den aktuellen Scope und liefert eine Liste gefundener Fehler zurück.
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Forbids public methods without an explicit return type declaration.
*
* @implements Rule<ClassMethod>
*/
final class RequirePublicReturnTypeRule implements Rule
{
public function getNodeType(): string
{
return ClassMethod::class;
}
/**
* @param ClassMethod $node
* @return list<\PHPStan\Rules\IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
if (!$node->isPublic() || $node->returnType !== null) {
return [];
}
return [
RuleErrorBuilder::message(sprintf(
'Public method %s() has no return type declaration.',
$node->name->toString()
))
->identifier('app.missingReturnType')
->build(),
];
}
}
Eigene Regeln werden über die services-Sektion in phpstan.neon registriert, mit dem Klassennamen und optional einem tags: [phpstan.rules.rule]-Eintrag, und laufen dann als vollwertiger Teil jeder Analyse neben den eingebauten Prüfungen der höchsten Stufe. Für die Absicherung eigener Regeln stellt PHPStan die Basisklasse PHPStan\Testing\RuleTestCase bereit, die Testfälle mit erwarteten Fehlermeldungen und Zeilennummern gegen Fixture-Dateien prüft, sodass eine eigene Regel genauso zuverlässig getestet wird wie jeder andere Teil der Codebasis.
8. CI-Integration: PHPStan Level max in der Pipeline durchsetzen
Die Grundlage jeder CI-Integration ist eine feste Composer-Abhängigkeit über composer require --dev phpstan/phpstan, ergänzt um phpstan/phpstan-strict-rules für zusätzliche Strenge und gegebenenfalls Erweiterungen für verwendete Frameworks oder Test-Bibliotheken. Ein composer.json-Skript-Eintrag macht den Aufruf für alle Entwickler und die Pipeline identisch, unabhängig davon, ob lokal oder in CI ausgeführt wird.
{
"scripts": {
"analyse": "phpstan analyse --memory-limit=1G",
"analyse:baseline": "phpstan analyse --generate-baseline=phpstan-baseline.neon",
"test": ["@analyse", "phpunit"]
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"phpstan/phpstan-strict-rules": "^1.6"
}
}
In der Pipeline selbst zahlt sich das eingebaute Result-Cache-Feature aus: PHPStan analysiert bei wiederholten Läufen standardmäßig nur geänderte Dateien neu, sofern der Cache-Ordner zwischen Pipeline-Läufen erhalten bleibt, etwa über einen CI-Cache-Key auf Basis der composer.lock. Bei großen Projekten reduziert das die Analysezeit erheblich, von mehreren Minuten auf wenige Sekunden bei unveränderten Dateien. Für Multi-Core-Runner beschleunigt --memory-limit in Kombination mit paralleler Prozessausführung, die PHPStan standardmäßig selbst übernimmt, die Analyse zusätzlich.
Ein entscheidender Punkt für die CI-Integration ist, den Build ausschließlich bei neuen, nicht in der Baseline enthaltenen Fehlern fehlschlagen zu lassen, nicht bei jedem historischen Eintrag. Das hält die Pipeline grün für bestehenden Code, während jeder neue Pull Request denselben strengen Maßstab an neuen oder geänderten Code anlegt. Ein separater, nicht blockierender Report-Job, der die Anzahl der Baseline-Einträge über die Zeit trackt, macht zusätzlich sichtbar, ob technische Schuld ab- oder zunimmt.
9. PHPStan-Level im direkten Vergleich
Die folgende Übersicht zeigt, welche Fehlerklassen ein Projekt auf welchem Level typischerweise aufdeckt, und dient als grobe Orientierung für die Planung des eigenen Level-Ratchetings hin zu PHPStan Level max.
| Level | Was typischerweise geprüft wird | Empfehlung |
|---|---|---|
| 0 | Syntaxfehler, unbekannte Klassen, Funktionen und Konstanten | Einstieg bei Legacy-Code ohne jede Analyse-Historie |
| 5 | Argumenttypen und Rückgabetypen grob geprüft, einfache Typkonflikte | Guter Startpunkt für aktiv gepflegte Projekte |
| 8 | Methodenaufrufe auf möglicherweise null, strengere Nullability-Prüfung |
De-facto-Standard für neue Projekte |
| 9 | Striktere Typkompatibilität, Mixed-Type-Missbrauch, unvollständige Generics | Für Bibliotheken mit öffentlicher API |
| max | Alle verfügbaren Regeln inkl. neuester Checks, feinste Array-Shape- und Generics-Prüfung | Ziel für Projekte mit hoher Qualitätsanforderung |
Der Sprung zwischen den unteren Levels kostet in der Regel wenig Aufwand, weil hauptsächlich offensichtliche Fehler wie unbekannte Symbole gemeldet werden. Der größte Migrationsaufwand liegt meist zwischen Level 5 und 8, wenn Nullability-Prüfungen und strengere Typkompatibilität einsetzen. Der letzte Schritt zur Maximalstufe fügt selten fundamental neue Fehlerklassen hinzu, sondern verschärft bestehende Prüfungen und schließt letzte Lücken bei Generics und Array Shapes, weshalb er sich in einem gut vorbereiteten Projekt oft schneller erledigen lässt als die mittleren Levels.
10. Zusammenfassung
PHPStan Level max ist kein Zustand, den man an einem Wochenende erreicht, sondern das Ergebnis eines strukturierten Prozesses. Level-Ratcheting hebt das Level in kleinen, überschaubaren Schritten an, statt das Projekt mit einem einzigen riesigen Fehlerberg zu überfordern. Die phpstan-baseline.neon friert bekannten Bestandscode ein, ohne ihn zu ignorieren, und erzwingt gleichzeitig, dass neuer Code dem strengsten Standard folgt. Generics über @template und @extends sowie Array Shapes schließen Typlücken, die PHP selbst nicht abbilden kann, und machen gerade die strengsten Prüfungen erst wirklich präzise.
Strictness-Parameter wie checkMissingIterableValueType oder treatPhpDocTypesAsCertain erlauben es, einzelne Verschärfungen gezielt vorzuziehen, statt alles gleichzeitig einzuführen. Eigene Regeln über das Rule-Interface schließen die Lücke zwischen generischer Static Analysis und projektspezifischen Konventionen. Und eine saubere CI-Integration sorgt dafür, dass die Zielstufe nicht zu einer einmaligen Übung wird, sondern dauerhaft Teil des Entwicklungsworkflows bleibt, mit Baseline-Wachstum als messbarer Metrik für technische Schuld.
PHPStan Level max für ein wachsendes Projekt - Das Wichtigste auf einen Blick
Level-Ratcheting
Level schrittweise anheben, jede Stufe als eigener Commit, statt mit einem Sprung direkt auf die Maximalstufe zu wechseln.
Baseline-Strategie
phpstan-baseline.neon friert Bestandscode ein, neue Fehler in neuem Code fallen sofort auf, regelmäßig neu generieren.
Generics & Array Shapes
@template, @extends und array{key: type} schließen Typlücken, die PHP ohne native Generics offen lässt.
CI-Integration
Build nur bei neuen Fehlern fehlschlagen lassen, Result-Cache zwischen Läufen erhalten, Baseline-Größe als Metrik tracken.
11. FAQ: PHPStan Level max
1Was ist PHPStan Level max?
2Warum nicht direkt auf die höchste Stufe springen?
3Was macht eine phpstan-baseline.neon?
4Wie halte ich die Baseline aktuell?
5Was bringen @template und Generics in PHPDoc?
6Was ist ein Array Shape?
7Was macht treatPhpDocTypesAsCertain?
8Wie schreibe ich eine eigene PHPStan-Regel?
9Wie integriere ich die höchste Analyse-Stufe in CI?
10Wie lange dauert der Weg zur Maximalstufe?
Mironsoft
Static Analysis, Code-Qualität und CI-Pipelines für PHP-Projekte
Euer Projekt läuft noch auf einem niedrigen PHPStan-Level?
Wir analysieren eure Codebasis, planen ein realistisches Level-Ratcheting bis PHPStan Level max, richten Baseline-Dateien sauber ein und integrieren die Analyse fest in eure CI-Pipeline.
PHPStan-Audit
Bestandsaufnahme des aktuellen Levels und Roadmap für das Level-Ratcheting
Baseline-Setup
phpstan-baseline.neon pro Modul, Generics und Array Shapes für kritische Kernklassen
CI-Integration
Result-Cache, Baseline-Tracking und Build-Regeln, die nur bei echten neuen Fehlern greifen