Drei Tage. So lange habe ich gebraucht, um einen Payment-Reconciliation-Bug zu fixen, den PHPStan in drei Sekunden gefunden hätte.
Oder: Wie ich lernte, das Type System zu lieben
Der Bug war subtil. Unser API-Wrapper hat null zurückgegeben, wenn die API in
Wartung war – aber nur für bestimmte Transaction-Types. Unsere Tests haben mit
Mocks gearbeitet, die nie null zurückgeben. Das Code-Review hat sich auf die
Berechnungs-Logik konzentriert, nicht auf Null-Safety. Production? Production
ist um 3 Uhr morgens an einem Sonntag abgestürzt.
PHPStan Level 9 hätte uns angeschrien: “Parameter #2 $bankData of function reconcileTransaction() expects BankData, BankData|null given.”
Dieser 3-Uhr-morgens-Incident hat verändert, wie ich über Type Safety denke. Heute zeige ich dir, wie wir 90% unserer typ-bezogenen Bugs eliminiert haben, ohne einen einzigen zusätzlichen Test zu schreiben. Das Geheimnis? Dein Compiler ist schlauer als deine Tests.
Warum Static Analysis alles verändert
Kennste das noch aus Production?
Fatal error: Uncaught Error: Call to a member function
format() on null in /var/www/app/PaymentProcessor.php:142
Das passiert, weil PHP dynamisch typisiert ist. Variablen können zur Runtime alles sein. Deine IDE warnt dich vielleicht, deine Tests fangen 80% der Fälle ab, aber die 20% finden ihren Weg in Production – zum ungünstigsten Zeitpunkt.
Static Analysis Tools wie PHPStan analysieren deinen Code ohne ihn auszuführen. Sie bauen einen kompletten Type-Graph deiner Anwendung auf und sagen dir genau, wo Dinge kaputtgehen werden, bevor du deployst.
Der Unterschied ist enorm:
Traditionelles Testing:
- Code schreiben
- Tests schreiben
- Tests laufen lassen
- Deployen
- Production-Bugs fixen, die du nicht getestet hast
Static Analysis:
- Code schreiben
- PHPStan laufen lassen
- Type-Errors fixen
- Mit Confidence deployen
- Die ganze Nacht durchschlafen
Die PHPStan Level Journey (0-9)
PHPStan bietet 10 Strictness-Level, von “kaum irgendwas checken” bis “dein Code ist mathematisch bewiesen fehlerfrei”. Jedes Level catcht progressiv subtilere Bugs.
Level 0: Basis Existenz-Checks
Der sanfteste Einstieg. PHPStan verifiziert, dass Klassen, Funktionen und Methoden, die du aufrufst, tatsächlich existieren.
// Level 0 catcht das hier
$user = new UserrModel(); // Typo im Klassennamen
$user->getName(); // Klasse existiert nicht, Methode existiert nicht
// Level 0 erlaubt das hier (sollte es aber nicht)
function process($data) {
return $data->value; // Was wenn $data null ist?
}
Was es catcht: Fatale Typos, undefined Classes, undefined Methods Was es misst: Alles was mit Types zu tun hat
Level 1-2: Unknown Classes und Methods
Checkt, ob du Methods aufrufst, die auf den richtigen Objekten existieren.
// Level 2 catcht das
$user = getUserById(123);
$user->nonExistentMethod(); // Method existiert nicht
// Level 2 erlaubt das
function formatUser($user) {
return $user->getName(); // Welcher Type ist $user?
}
Was es catcht: Methods auf falschen Object-Types aufrufen Was es misst: Fehlende Type Hints, Magic Methods
Level 3-4: Basic Type Checking
Jetzt wird’s ernst. PHPStan fängt an, grundlegende Type Hints zu verlangen und zu checken.
// Level 4 catcht das
function processUsers(array $users): void {
foreach ($users as $user) {
echo $user->getName(); // $user könnte alles sein!
}
}
// Level 4 verlangt das
/**
* @param User[] $users
*/
function processUsers(array $users): void {
foreach ($users as $user) {
echo $user->getName(); // Jetzt weiß PHPStan: $user ist User
}
}
Was es catcht: Fehlende Parameter-Types, fehlende Return-Types Was es misst: Nullable Types, Union Types, komplexe Generics
Level 5-6: Dead Code und Strict Comparisons
PHPStan checkt jetzt deine Logik, nicht nur deine Types.
// Level 5 catcht das
function getUser(int $id): ?User {
$user = $this->repository->find($id);
if ($user === null) {
return null;
}
return $user->getName(); // Falsch! Sollte User returnen, nicht string
}
// Level 6 catcht das
$result = someFunction(); // Returns mixed
if ($result) { // Loose comparison
// PHPStan: "Das funktioniert vielleicht nicht so, wie du denkst"
}
Was es catcht: Falsche Return-Types, Dead Code Paths, Loose Comparisons Was es misst: Komplexe Null-Safety-Szenarien
Level 7-8: Advanced Type Safety
Hier sollten die meisten Production-Apps hinwollen. Fast alles wird gecheckt.
// Level 7 catcht das
function processPayment(?PaymentData $data): void {
// Property-Zugriff auf potenziell null Object
$amount = $data->amount;
}
// Level 7 verlangt das
function processPayment(?PaymentData $data): void {
if ($data === null) {
throw new InvalidArgumentException('Payment data required');
}
$amount = $data->amount; // Safe!
}
// Level 8 catcht das
$users = getUsersFromCache(); // Returns mixed
foreach ($users as $user) { // Kann mixed nicht iterieren
// ...
}
Was es catcht: Die meisten Null Pointer Exceptions, Type Mismatches, unsichere Mixed-Usage Was es misst: Die strengsten Mixed-Type-Rules
Level 9: Maximum Strictness
Willkommen an der Spitze. Level 9 ist PHPStans strengstes Level (es gibt auch Level 10 in PHPStan v2, aber das ist experimentell).
Auf Level 9 wird PHPStan fast schon obsessiv beim mixed Type:
“Be strict about the mixed type - the only allowed operation you can do with it is to pass it to another mixed.”
// Level 9 lehnt das ab
function processData(mixed $data): void {
if (is_array($data)) {
count($data); // ERROR: Kann mixed nicht nutzen, auch nach is_array()
}
}
// Level 9 verlangt das
/**
* @param array<mixed> $data
*/
function processData(array $data): void {
count($data); // OK: $data ist array, nicht mixed
}
// Level 9 catcht diesen subtilen Bug
function getValue(): mixed {
return $this->cache->get('key');
}
function processValue(): void {
$value = $this->getValue();
// ERROR: Kann NICHTS mit $value machen
echo $value; // Nicht erlaubt
$value->method(); // Nicht erlaubt
$value + 5; // Nicht erlaubt
}
Was es catcht: Jede mögliche Type-Ambiguität Production Reality: Level 8 ist oft praktischer, Level 9 ist für die, die absolute Safety wollen
Das haben wir in Production gefunden, als wir von Level 6 auf Level 9 gegangen sind:
// Unser API Client (hat 2 Jahre lang funktioniert)
class ApiClient {
public function fetchTransaction(string $id) {
$response = $this->http->get("/transactions/{$id}");
return json_decode($response->getBody(), true);
// Returned mixed! Könnte null, array oder alles sein
}
}
// Unser Reconciliation-Code
public function reconcile(string $transactionId): void {
$data = $this->api->fetchTransaction($transactionId);
// Level 9: "Du kannst nicht auf Array-Keys von mixed zugreifen"
$amount = $data['amount'];
// Der Fix
/**
* @return array{amount: float, currency: string}|null
*/
public function fetchTransaction(string $id): ?array {
$response = $this->http->get("/transactions/{$id}");
$data = json_decode($response->getBody(), true);
if (!is_array($data)) {
return null;
}
return $data;
}
}
Diese eine Änderung hat den Bug verhindert, den ich am Anfang dieses Artikels erwähnt habe.
Die Baseline-Strategie: Migrieren ohne alles kaputt zu machen
Die Realität ist: Du hast wahrscheinlich eine Legacy-Codebase mit tausenden von PHPStan-Errors. Du kannst die nicht alle fixen, bevor du dein nächstes Feature shipped.
Die Baseline-Feature ist dein Escape Hatch.
Was ist eine Baseline?
Eine Baseline ist ein Snapshot aller aktuellen Errors. PHPStan ignoriert diese Errors in zukünftigen Runs, aber schreit dich an bei neuen Errors.
Das lässt dich:
- Ein hohes Strictness-Level setzen (wie Level 8)
- Alle existierenden Errors ignorieren
- Allen neuen Code zu diesem Standard zwingen
- Alte Errors inkrementell fixen
Deine erste Baseline generieren
# Baseline auf Level 8 generieren
vendor/bin/phpstan analyse \
--level 8 \
--configuration phpstan.neon \
src/ tests/ \
--generate-baseline
Das erstellt phpstan-baseline.neon:
parameters:
ignoreErrors:
- message:
"#^Method App\\\\PaymentProcessor::process\\(\\) has parameter \\$data
with no type specified\\.$#"
count: 1
path: src/PaymentProcessor.php
- message: "#^Property App\\\\User::\\$metadata has no type specified\\.$#"
count: 15
path: src/Models/User.php
- message:
"#^Method App\\\\ApiClient::fetchTransaction\\(\\) has no return type
specified\\.$#"
count: 1
path: src/ApiClient.php
Baseline einbinden
Update phpstan.neon:
includes:
- phpstan-baseline.neon
parameters:
level: 8
paths:
- src
- tests
Jetzt PHPStan laufen lassen:
vendor/bin/phpstan analyse
Output:
[OK] No errors
Schön. Jetzt schreib mal neuen Code mit einem Type-Error:
// Neues Feature, heute hinzugefügt
class InvoiceGenerator {
public function generate($invoice) { // Fehlender Type Hint
return $invoice->format();
}
}
PHPStan nochmal laufen lassen:
vendor/bin/phpstan analyse
Output:
------ ---------------------------------------------------------------
Line src/InvoiceGenerator.php
------ ---------------------------------------------------------------
3 Method App\InvoiceGenerator::generate() has parameter
$invoice with no type specified.
------ ---------------------------------------------------------------
[ERROR] Found 1 error
Das ist die Power von Baselines. Alter Code? Ignoriert. Neuer Code? Muss perfekt sein.
Inkrementelle Verbesserungs-Strategie
So haben wir unsere 150.000-Zeilen-Codebase über 4 Monate zu Level 9 migriert:
Woche 1-2: Baseline auf Level 6 generieren
vendor/bin/phpstan analyse --level 6 --generate-baseline
git add phpstan-baseline.neon
git commit -m "chore: add PHPStan baseline at level 6"
Woche 3-8: Ein Modul pro Sprint fixen
# Kleines Modul aussuchen
vendor/bin/phpstan analyse src/Billing/ --level 6
# Alle Errors in dem Modul fixen
# Dann diese Errors aus der Baseline entfernen
vendor/bin/phpstan analyse --generate-baseline
Woche 9-12: Auf Level 7 erhöhen
# Neue Baseline auf Level 7 generieren
vendor/bin/phpstan analyse --level 7 --generate-baseline
# Weiter Modul für Modul fixen
Woche 13-16: Level 8 (Production Goal)
Monat 4: Level 9 für Critical Paths
Wir sind nicht überall auf Level 9 gegangen. Wir haben Directory-spezifische Configs genutzt:
# phpstan.neon
parameters:
level: 8
paths:
- src
- tests
# phpstan-strict.neon (für kritischen Code)
includes:
- phpstan.neon
parameters:
level: 9
paths:
- src/Billing
- src/PaymentProcessing
- src/Security
Jetzt lassen wir zwei Checks laufen:
# Regular Check (Level 8)
vendor/bin/phpstan analyse
# Strict Check für kritischen Code (Level 9)
vendor/bin/phpstan analyse -c phpstan-strict.neon
Custom Rules: Erzwinge deine Team-Conventions
PHPStans eingebaute Rules sind exzellent, aber jedes Team hat eigene Conventions. Custom Rules lassen dich die automatisch durchsetzen.
Beispiel: Factory Pattern Enforcement
Sagen wir mal, dein Team nutzt Factories für Domain Models und du willst direkte Instanziierung verhindern:
// Schlecht: Direkte Instanziierung
$user = new User($name, $email);
// Gut: Factory nutzen
$user = UserFactory::create($name, $email);
Custom Rule erstellen:
<?php declare(strict_types = 1);
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PhpParser\Node\Expr\New_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<New_>
*/
class NoDirectDomainModelInstantiationRule implements Rule
{
public function getNodeType(): string
{
return New_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $node->class->toString();
// Checken, ob das ein Domain Model ist
if (!str_starts_with($className, 'App\\Domain\\')) {
return [];
}
// Instanziierung in Factories und Tests erlauben
$currentFile = $scope->getFile();
if (str_contains($currentFile, 'Factory') ||
str_contains($currentFile, 'Test')) {
return [];
}
return [
RuleErrorBuilder::message(
sprintf(
'Domain Model %s sollte nicht direkt instanziiert werden. Nutze %sFactory.',
$className,
$className
)
)
->identifier('app.domainModel.directInstantiation')
->build(),
];
}
}
In phpstan.neon registrieren:
rules:
- App\PHPStan\Rules\NoDirectDomainModelInstantiationRule
Jetzt erzwingt PHPStan deine Architektur:
// Das triggert unsere Custom Rule
$user = new User('John', 'john@example.com');
// Error: Domain Model App\Domain\User sollte nicht
// direkt instanziiert werden. Nutze App\Domain\UserFactory.
Mehr praktische Custom Rules
Langsame Funktionen in Hot Paths verhindern:
/**
* @implements Rule<FuncCall>
*/
class NoSlowFunctionsInHotPathRule implements Rule
{
private const SLOW_FUNCTIONS = [
'sleep',
'usleep',
'file_get_contents', // Ohne Caching
];
public function processNode(Node $node, Scope $scope): array
{
$functionName = $node->name->toString();
$currentFile = $scope->getFile();
// Checken, ob wir in einem Hot Path sind
if (!str_contains($currentFile, '/HotPath/')) {
return [];
}
if (in_array($functionName, self::SLOW_FUNCTIONS, true)) {
return [
RuleErrorBuilder::message(
sprintf('Langsame Funktion %s() sollte nicht in Hot Paths genutzt werden', $functionName)
)->build(),
];
}
return [];
}
}
Spezifische PHPDoc für Public APIs verlangen:
/**
* @implements Rule<ClassMethod>
*/
class PublicApiMustHaveExamplesRule implements Rule
{
public function processNode(Node $node, Scope $scope): array
{
// Nur public Methods in src/Api checken
if (!$node->isPublic()) {
return [];
}
$currentFile = $scope->getFile();
if (!str_contains($currentFile, '/Api/')) {
return [];
}
$docComment = $node->getDocComment();
if ($docComment === null || !str_contains($docComment->getText(), '@example')) {
return [
RuleErrorBuilder::message(
'Public API Methods müssen @example im PHPDoc haben'
)->build(),
];
}
return [];
}
}
CI/CD Integration: Mach’s automatisch
Static Analysis ist nur nützlich, wenn sie automatisch läuft. So integrierst du PHPStan in deine Pipeline.
GitHub Actions
# .github/workflows/phpstan.yml
name: PHPStan
on: [push, pull_request]
jobs:
phpstan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github
- name: Run PHPStan (Strict) auf kritischem Code
run:
vendor/bin/phpstan analyse -c phpstan-strict.neon
--error-format=github
GitLab CI
# .gitlab-ci.yml
phpstan:
image: php:8.3-cli
stage: test
before_script:
- apt-get update && apt-get install -y git unzip
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install
script:
- vendor/bin/phpstan analyse --error-format=gitlab
artifacts:
reports:
codequality: phpstan-report.json
Pre-commit Hook (Local Development)
#!/bin/bash
# .git/hooks/pre-commit
echo "Running PHPStan..."
# Liste der PHP Files im Staging holen
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
if [ -z "$FILES" ]; then
exit 0
fi
# PHPStan nur auf geänderten Files laufen lassen
vendor/bin/phpstan analyse $FILES --level=8
if [ $? -ne 0 ]; then
echo ""
echo "PHPStan hat Errors gefunden. Commit abgebrochen."
echo "Fix die Errors oder nutze 'git commit --no-verify' zum Skippen."
exit 1
fi
exit 0
Ausführbar machen:
chmod +x .git/hooks/pre-commit
Progressive CI-Strategie
Wir nutzen einen gestuften Ansatz:
# .github/workflows/quality.yml
name: Code Quality
on: [push, pull_request]
jobs:
phpstan-baseline:
name: PHPStan (Baseline)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer install
- run: vendor/bin/phpstan analyse
# Muss passen (nutzt Baseline)
phpstan-strict:
name: PHPStan (Ohne Baseline)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer install
- run: vendor/bin/phpstan analyse --level 9
# Kann fehlschlagen (zeigt Progress zu Level 9)
Der erste Job blockiert das Mergen, wenn neuer Code Errors hat. Der zweite Job zeigt, wie viele Baseline-Errors übrig sind, blockiert aber nicht.
PHPStan vs Psalm: Was sollst du wählen?
Beide sind exzellent. Hier meine Einschätzung nach beiden in Production:
PHPStan Stärken
✅ Größeres Ecosystem - Mehr Extensions (Doctrine, Symfony, Laravel) ✅ Besserer Laravel Support - Larastan ist built-in in Laravel 10+ ✅ Einfachere Config - NEON-Format ist leichter als Psalms XML ✅ Sanftere Learning Curve - Leichter inkrementell einzuführen ✅ Bessere Error Messages - Meist klarer, was falsch ist
Psalm Stärken
✅ Taint Analysis - Catcht Security-Issues (SQL Injection, XSS) ✅ Fortgeschrittenere Type Inference - Manchmal schlauer bei Generics ✅ Immutability Checking - Built-in Support für @psalm-immutable ✅ Language Server - Bessere IDE-Integration out of the box
Meine Empfehlung
Nutze PHPStan wenn:
- Du Laravel, Symfony oder Doctrine nutzt
- Du das einfachste Onboarding für dein Team willst
- Du extensive Framework-spezifische Extensions brauchst
- Du YAML/NEON über XML bevorzugst
Nutze Psalm wenn:
- Security paramount ist (Taint Analysis ist Gold)
- Du komplexe Generic Types hast
- Du das strengstmögliche Checking willst
- Du XML-Config bevorzugst
Nutze beide wenn:
- Du dir die CI-Zeit leisten kannst
- Du maximale Safety willst
- Du kritische Security-Anforderungen hast
Wir nutzen PHPStan auf Level 8 mit Strict Rules für den meisten Code, und wir lassen Psalm mit Taint Analysis nur auf Security-sensitiven Modulen laufen (Authentication, Payment Processing, Admin Panels).
# .github/workflows/security.yml
security-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
- run: composer require --dev vimeo/psalm
- run: vendor/bin/psalm --taint-analysis src/Security/
Real Production Impact: Die Zahlen
Das ist passiert, als wir PHPStan Level 8 in unser Payment-Processing-System eingeführt haben:
Vor PHPStan:
- Production Errors: 15-20 pro Woche
- Typ-bezogene Bugs: ~60% aller Bugs
- Durchschnittliche Fix-Zeit: 45 Minuten
- P1 Incidents: 1-2 pro Monat
Nach PHPStan (6 Monate):
- Production Errors: 2-3 pro Woche (85% Reduktion)
- Typ-bezogene Bugs: ~10% aller Bugs (83% Reduktion)
- Durchschnittliche Fix-Zeit: 15 Minuten (früher gefangen)
- P1 Incidents: 1 in 6 Monaten
Developer Experience:
- Initial Setup-Zeit: 3 Tage
- Baseline Cleanup: 4 Monate (inkrementell)
- Täglicher Overhead: ~2 Minuten pro PR (automatisiert)
- Verhinderte Bugs: Unzählige (wörtlich, sie sind nie passiert)
Der ROI ist unbestreitbar. Die 3-Uhr-morgens-Pages haben aufgehört. Code Reviews wurden schneller, weil wir aufgehört haben, “was wenn das null ist?” zu diskutieren. Das Confidence zum Refactoring hat sich dramatisch verbessert.
Deine Migrations-Checkliste
Bereit, deine PHPStan-Journey zu starten? Hier deine Roadmap:
Woche 1: Setup
- [ ] PHPStan installieren:
composer require --dev phpstan/phpstan - [ ] Basis-Config erstellen:
phpstan.neon - [ ] Level 0 laufen lassen:
vendor/bin/phpstan analyse --level 0 src/ - [ ] Alle Level-0-Errors fixen (sollte easy sein)
Woche 2: Baseline etablieren
- [ ] Target-Level wählen (empfehle Level 6 oder 7)
- [ ] Baseline generieren:
vendor/bin/phpstan analyse --level 7 --generate-baseline - [ ] Baseline zur Config hinzufügen
- [ ] In CI integrieren
Monat 1: Auf neuem Code enforc’en
- [ ] Alle PRs müssen PHPStan passen
- [ ] Keine neuen Baseline-Entries erlaubt
- [ ] Errors in Files fixen, die du anfasst
Monat 2-3: Inkrementelles Cleanup
- [ ] 1-2 Module pro Sprint aussuchen
- [ ] Aus Baseline entfernen
- [ ] Baseline updaten
Monat 4+: Strictness erhöhen
- [ ] Baseline auf Level 8 neu generieren
- [ ] Strict Rules Extension hinzufügen
- [ ] Level 9 für kritischen Code in Betracht ziehen
Bonus: Advanced
- [ ] Custom Rules für Team-Conventions schreiben
- [ ] Strict Config für Critical Paths splitten
- [ ] Psalm für Security-Analysis hinzufügen
- [ ] PHPStan Pro für bessere Errors enablen
Das Fazit
Drei Tage Debugging versus drei Sekunden Static Analysis. Das ist die Wahl.
PHPStan Level 9 repräsentiert den Gipfel der Type Safety in PHP. Es ist nicht immer praktisch für jede Code-Zeile, aber die Journey von Level 0 zu Level 8+ wird deine Codebase transformieren. Du catcht Bugs, bevor sie geschrieben werden. Du refactorst mit Confidence. Du schläfst die Sonntag-Nächte durch.
Die Baseline-Strategie macht Migration schmerzlos. Custom Rules enforc’en deine Conventions automatisch. CI-Integration macht alles automatisch.
Starte heute. Run composer require --dev phpstan/phpstan. Generier die
Baseline. Watch wie deine Production-Errors droppen.
Dein zukünftiges Ich – das, das nicht um 3 Uhr morgens Null-Pointer-Exceptions debugged – wird dir danken.
Ressourcen
- PHPStan Dokumentation
- PHPStan Baseline Feature
- Legacy Code mit PHPStan fixen
- PHPStan Level 9 erreichen
- PHPStan vs Psalm Vergleich
- Static Analysis Best Practices
Was ist deine Horror-Story mit typ-bezogenen Bugs? Hast du PHPStan oder Psalm ausprobiert? Welches Level läuft bei dir in Production? Ich würde gerne deine Migrations-Strategien in den Kommentaren hören.