Eine eigentlich unauffällige Regeländerung in 8.4 — und am dritten Tag nach dem Deploy stand das Sentry-Log so voll, dass die Suche nach echten Fehlern keinen Spaß mehr machte. Kein Crash, kein verändertes Verhalten, „nur" Deprecations. Was sich genau geändert hat, warum es in Legacy-Code anders aussieht als in einer modernen Symfony-Anwendung, und wie ich es üblicherweise abarbeite.
Anfang Mai ein älteres Symfony-Projekt von 6.4 auf 7.1 gehoben, PHP gleich mit von 8.2 auf 8.4. Smoke-Test grün, deployed, gut. Am Mittwochnachmittag meldet sich Sentry mit 38.214 neuen Events innerhalb von zwei Stunden. Alles E_USER_DEPRECATED, kein einziger Error. Schuld war eine Mikro-Änderung, die in den 8.4er-Release-Notes ganz oben steht, aber so unscheinbar formuliert ist, dass man sie beim Querlesen halt überfliegt: Deprecate implicitly nullable parameter types.
Was sich in 8.4 ändert
Bisher konnte ich in PHP einen typisierten Parameter „nullable" machen, indem ich einfach null als Default mitgab — und der Typ blieb formal string, int, was auch immer:
// alt: implizit nullable, bis 8.3 akzeptiert, in 8.4 deprecated
public function findUser(string $email = null): ?User
{
if ($email === null) {
return null;
}
return $this->repo->findOneBy(['email' => $email]);
}
// neu: explizit nullable
public function findUser(?string $email = null): ?User
{
// Rumpf unverändert
}
Der Unterschied ist genau ein ?. Die Deprecation feuert übrigens nicht erst, wenn jemand explizit findUser(null) aufruft — schon der Aufruf ohne Argument (findUser()) reicht, weil dann der Default null implizit greift, obwohl die Signatur string sagt. In einer durchschnittlichen Anwendung passiert das eben permanent.
PHP hat das implizite Verhalten zwar seit 7.0 mitgeschleppt, war aber innerlich immer uneins damit. ReflectionParameter::allowsNull() gab true zurück, ReflectionParameter::getType()->allowsNull() aber false. Static-Analyzer mussten den Sonderfall extra kennen, und für Reflection-basierten Code — Doctrine-Listener, Symfony-Form-Resolver, Validator — war die Signatur an einer Stelle anders als an der nächsten, je nachdem, welche der beiden Methoden gefragt wurde. 8.4 räumt diese Doppelsemantik auf. Wer null als Default will, schreibt halt explizit ? vor den Typ.
Der Code läuft weiter wie bisher. Aber jeder Aufruf einer betroffenen Funktion produziert einen Deprecation-Hinweis, und in einer ordentlich frequentierten Anwendung sind das eben schnell zehntausende pro Stunde.
Wo das in modernen Codebases ein halber Tag ist
Bei einer aktuellen Symfony-Anwendung mit declare(strict_types=1) und ein paar Jahren PHPStan auf Level 6+ ist die Migration ein Nachmittag. Rector kennt die Regel, läuft drüber, fertig. In dem Projekt von oben waren es nach dem ersten Lauf 247 betroffene Stellen — fast alles in Repository-Klassen aus 2018 und einem Stapel Form-Types aus der Frühzeit. Knapp drei Stunden Review, ein Test-Durchlauf, dann sauber gemerged.
Das ist die langweilige Variante. Genau die kriege ich aber selten auf den Tisch. Was ich übernehme, sieht meistens anders aus.
Wo das in Legacy-Code beißt
Ein konkretes Beispiel: Eine Logistik-Anwendung, die ich seit 2021 begleite. Symfony 4.4 mit PHP 7.4 zum Übernahmezeitpunkt, mittlerweile auf 8.2. Beim Probelauf gegen 8.4 zählt der Sampler 1.412 unterschiedliche Callsites mit dem alten Pattern. Die meisten davon harmlos. Aber:
47 davon sitzen in abstract methods oder Interface-Deklarationen. Das heißt: ändere ich die Eltern-Signatur, müssen alle Kinder mit. In dieser Codebase: 312 Implementierungen. 23 sind in Doctrine-Listenern, die per Reflection aufgerufen werden — da reicht das Anpassen der Signatur nicht, weil der Aufruf-Code im Framework sein eigenes Ding mit null macht und der Listener intern beide Pfade beherrschen muss, bis das Framework selbst nachzieht. Und ungefähr ein Drittel der angefassten Funktionen hat überhaupt keinen Typ am Parameter — der Default ist null, und sonst ist da nichts. Strenggenommen wirft PHP 8.4 für diese Stellen gar keine Deprecation, weil ohne deklarierten Typ auch nichts verletzt wird; null ist bei untypisiertem mixed eben legitim. Sie fallen mir nur deshalb auf, weil ich die ganze Datei sowieso unter die Lupe nehme.
Genau dieses dritte Drittel ist aber die eigentliche Arbeit. Die Deprecation in 8.4 ist nur der Auslöser, der das größere Problem sichtbar macht: Wo null als Default steht, ohne dass jemals klar deklariert wurde, was sonst noch reinkommen darf, da hat die Codebase ein Typ-Problem, kein 8.4-Problem. Mit dem stumpfen Rector-Lauf macht man’s hier sogar schlimmer, sobald man Rector erlaubt, anhand von Docblocks (@param string|null) Typen nachzutragen — dann steht plötzlich ?string an einer Stelle, an der die Funktion seit Jahren auch Arrays und Objekte schluckt.
Mein Vorgehen bei untyped Legacy
Reihenfolge ist hier wichtiger als Geschwindigkeit. Rector kommt zum Schluss, nicht zuerst.
Erstens: PHPStan-Baseline auf Level 2. Höhere Level produzieren in einer ungepflegten Codebase so viel Lärm, dass die Baseline nicht mehr lesbar ist. Level 2 fängt mir die meisten Type-Inkonsistenzen ein, ohne dass das Backlog explodiert. Und es zeigt mir die Stellen, an denen null als Default eigentlich kein Default ist, sondern ein „weiß-ich-noch-nicht".
Zweitens: Characterization Tests, wo der Typ unklar ist. Wenn $email = null reinkommt und am Ende null rauskommt, ist die Funktion trivial nullable. Wenn aber $email = null reinkommt und der Code dann plötzlich aus einer drei Jahre alten Session-Variable rekonstruiert, was der Aufrufer eigentlich gemeint haben muss — das ist historisch gewachsenes Verhalten, das ich vor dem Anfassen einfriere. Wie das geht, hab ich im letzten Logbuch beschrieben.
Drittens: Datei für Datei declare(strict_types=1) setzen. Nicht global, sondern jede Datei einzeln, mit Smoke-Test dazwischen. Wer das auf einmal macht, hat hinterher mehrere hundert Fehler und keine Idee mehr, welcher wo hingehört.
Viertens: Rector. Mit der Regel Rector\Php84\Rector\Param\ExplicitNullableParamTypeRector. Bei der Logistik-Codebase hat ein erster Lauf 1.247 Stellen umgeschrieben — also etwas mehr als der ursprüngliche Sampler-Wert, weil zwischenzeitlich Typen ergänzt waren. Davon waren 1.180 sauber, 67 brauchten Handarbeit. Meist Stellen, wo der null-Default eigentlich falsch war und ich ihn beim selben Atemzug entfernt habe, statt das implizite Verhalten zu zementieren.
Das Ganze über sechs Wochen verteilt, ein paar Stunden pro Woche. Nicht aufregend, aber linear. Und am Ende ist die Codebase nicht nur 8.4-fit, sondern an einer ganzen Reihe von Stellen klarer als vorher. Was bei Legacy-Projekten oft die größere Belohnung ist als das nominelle Versions-Update.
Stolperfallen
Drei Sachen, in die ich selbst schon getreten bin oder die ich regelmäßig sehe:
Interface- und abstract-method-Kaskaden. Wenn die Eltern-Signatur string $key = null hat und ich sie auf ?string $key = null ziehe, müssen alle Implementierungen mit — wegen LSP würde sonst die Vererbungs-Validierung selbst feuern. Manche IDEs warnen davor nicht zuverlässig, PHPStan ab Level 2 schon. Deshalb steht der Schritt vor Rector und nicht danach.
Vendor-Code. Wenn meine Anwendung eine Library aufruft, die selber noch die alte Syntax verwendet, kommt die Deprecation aus dem vendor/-Verzeichnis. Reparieren kann ich das von außen nicht — der Pull Request gehört nach Upstream. Was hilft, ist im Error-Handler oder per error_reporting() die Deprecations aus vendor/-Pfaden gezielt zu filtern, bis das Upstream-Update da ist. Hässlich, aber ehrlicher als alle Deprecations pauschal abzuschalten und dabei die eigenen mitzuverlieren.
Deprecations als Fehlschlag im Test-Setup. PHPUnit hat das in Version 10 umgestellt — das alte convertDeprecationsToExceptions="true" gibt es nicht mehr. Stattdessen sammelt PHPUnit Deprecations standardmäßig nur noch ein und zeigt sie am Ende des Laufs als Zusammenfassung. Rot wird die Suite erst, wenn man failOnDeprecation="true" in der phpunit.xml oder den CLI-Flag --fail-on-deprecation aktiv hat. Wer das so eingestellt hat (und das sollte man bei einer gepflegten Codebase eigentlich auch), kriegt nach dem 8.4-Upgrade eine tiefrote Testsuite — nicht weil sich Verhalten geändert hat, sondern weil jede betroffene Funktion pro Aufruf einen Treffer landet. Sinnvolle Reihenfolge: vor dem Upgrade failOnDeprecation ausschalten, beim Upgrade die Deprecations Stück für Stück abarbeiten, dann erst wieder scharf stellen. Sonst hat man nach einem Nachmittag Migrationsarbeit eine Testsuite, die aus Versehen rot ist, und verliert die Aussagekraft des Reds für die echten Probleme.
Was Rector kann — und was nicht
Die Regel macht genau das, was sie verspricht: typisierte Parameter mit = null-Default auf ?Type umschreiben. Mechanisch, zuverlässig, schnell. Sie macht nicht:
Die Frage beantworten, ob der Default null an dieser Stelle überhaupt richtig ist. Typen ergänzen, wo gar keine stehen. Interface- und abstract-method-Kaskaden konsistent durchziehen. Entscheiden, ob ein Listener intern auf den null-Pfad reagieren muss, weil das Framework ihn weiter so aufruft.
Heißt: Rector ist der mechanische Schluss eines Prozesses, dessen Anfang und Mitte ich selbst mache. Das ist auch nicht sein Job. Aber es ist die Stelle, an der Migrations-Erzählungen gerne abkürzen — „ein Rector-Lauf, fertig" — und an der dann nach dem Deploy die unschönen Überraschungen kommen.
Schluss
Wenn die Codebase typisiert ist und ein bisschen Test-Abdeckung hat, ist die 8.4-Migration für diesen Punkt ein halber Tag. Wenn nicht, ist sie der Anlass, Dinge zu sortieren, die seit Jahren ungeordnet liegen — und der erzwingt sich gerade selbst, weil die Logs sonst zumüllen. Was nicht schlimm ist. Aber es ist eben nicht „kurz Rector drüberlaufen lassen", auch wenn die Release-Notes das so klingen lassen.
Genau diese Sortier-Arbeit übernehme ich regelmäßig: PHP-Upgrades in gewachsenen Codebases, im laufenden Betrieb, ohne Big-Bang. Termin buchen.