PHP 7.4 auf PHP 8.3 upgraden, ohne die Produktion zu gefährden
Der Security-Support für PHP 7.4 endete im November 2022. Jede seitdem veröffentlichte CVE ist auf dieser Runtime ungepatcht, und trotzdem läuft eine erstaunliche Zahl an Produktivanwendungen noch darauf. Der übliche Grund ist Angst: Die App verdient Geld, die letzte Person, die den Deploy-Prozess verstanden hat, ist vor zwei Jahren gegangen, und niemand will derjenige sein, der wegen eines Versionssprungs den Checkout kaputt gemacht hat.
Diese Angst lässt sich in den Griff bekommen. Ein PHP 7.4 auf 8.3 Upgrade ist ein gut ausgetretener Pfad mit ausgereiftem Tooling, einer kurzen Liste bekannter Fallen und einer Teststrategie, die fast alles abfängt, bevor ein Nutzer es sieht. Dieser Beitrag geht den Prozess in der Reihenfolge durch, in der wir ihn in Kundenprojekten fahren: die Lücke messen, die mechanische Arbeit automatisieren, die Verhaltensänderungen manuell prüfen, alles in CI beweisen und dann die Produktion umschalten, mit einem Rollback in der Hinterhand.
Warum 8.3, und warum jetzt
Das Sicherheitsargument ist das offensichtliche, aber meist beißt das Abhängigkeitsargument zuerst. Das Composer-Ökosystem ist weitergezogen: Neue Releases der Bibliotheken, auf die du dich verlässt, setzen PHP 8.1 oder neuer voraus. Eine 7.4-App hängt damit an alten Bibliotheksversionen, die ihre eigenen ungepatchten Schwachstellen mitbringen. Je länger du wartest, desto größer wird diese Lücke, und desto mehr vom Upgrade musst du am Ende auf einmal erledigen.
Performance ist der angenehme Nebeneffekt. Zwischen den Engine-Arbeiten in 8.0 und den stetigen Optimierungen seitdem sehen reale Anwendungen nach dem Sprung typischerweise messbar besseren Durchsatz und geringeren Speicherverbrauch. Die genaue Zahl hängt von deinem Workload ab, also benchmarke deine eigene App, statt einem fremden Diagramm zu vertrauen.
Warum 8.3 statt 8.4 oder 8.5? Wegen der Abdeckung im Ökosystem. Inzwischen unterstützt jede gepflegte Bibliothek, jede PECL-Extension und jedes Distributionspaket 8.3. Es ist außerdem die Untergrenze für aktuelles Symfony-Tooling und die Version, die die meisten LTS-Distributionen ausliefern. Sicherheitsupdates gibt es bis Dezember 2027, was dir Zeit verschafft, den nächsten Sprung als routinemäßiges Minor-Upgrade zu machen statt als weiteres Rettungsprojekt. Alles in diesem Leitfaden gilt unverändert, wenn du dich stattdessen für 8.4 entscheidest.
Erst den Kompatibilitätsbericht bauen, dann Code anfassen
Fang nicht damit an, die Version zu ändern und zu schauen, was explodiert. Fang mit drei Befehlen an, die dir sagen, wie groß die Aufgabe ist.
Erstens: Frag Composer, welche Abhängigkeiten die Zielversion blockieren:
composer why-not php 8.3
Jede Zeile der Ausgabe ist ein Paket, das ein Update, einen Ersatz oder einen Fork braucht. Diese Liste entscheidet meist mehr über den Zeitplan als dein eigener Code.
Zweitens: Lass PHP_CodeSniffer mit dem PHPCompatibility-Regelsatz über deinen Quellcode laufen:
composer require --dev phpcompatibility/php-compatibility
vendor/bin/phpcs -p src/ --standard=PHPCompatibility --runtime-set testVersion 8.3
Das ist ein statischer Scan nach Syntax und Funktionen, die sich zwischen deiner aktuellen Version und 8.3 geändert haben: entfernte Funktionen wie create_function und each, Array-Zugriff mit geschweiften Klammern, deprecated dynamische Properties und so weiter. Bei dynamischem Code produziert er False Positives, aber es ist der schnellste Weg, eine Zahl echter Inkompatibilitäten pro Verzeichnis zu bekommen.
Drittens: Lass Rector im Dry-Run-Modus mit den PHP-Regelsätzen von 7.4 bis 8.3 laufen und lies den Diff-Stat. Du wendest noch nichts an. Du misst den mechanischen Anteil der Arbeit, und das Verhältnis zwischen dem Rector-Diff und den PHPCompatibility-Funden sagt dir, wie viel automatisiert und wie viel von Hand behoben wird.
Wenn du schon dabei bist, inventarisiere deine Extensions. composer.json deklariert einige über ext-*-Anforderungen, aber prüfe php -m auf der Produktionsmaschine für den Rest. Kompilierte Extensions wie redis, imagick oder amqp brauchen Builds für 8.3, und eine fehlende Extension, die am Deploy-Tag auffällt, ist ein vermeidbarer Notfall.
Lass Rector die mechanische Arbeit machen
Rector wendet die Versions-Regelsätze als Codemods an: Syntax für typisierte Properties, str_contains statt strpos-Vergleichen, Ersatz für entfernte Funktionen, Nullsafe-Operatoren dort, wo die alten Null-Checks eindeutig waren. Auf den meisten Codebasen erledigt es den Großteil der Änderungen korrekt, und wenn du die Ausgabe pro Verzeichnis prüfst, bleiben die Pull Requests klein genug, um sie wirklich zu lesen. Den Workflow haben wir in Rector für Legacy-PHP ausführlich beschrieben, inklusive der Reihenfolge der Regelsätze, die verhindert, dass Regeln gegeneinander arbeiten.
Widersteh der Versuchung, im selben Durchgang den Stil zu modernisieren. Constructor Promotion, Enums, Readonly Properties und der Rest der PHP 8.3 Features sind es wert, eingeführt zu werden, aber sie gehören in einen Folgeschritt. Einem Upgrade-Diff, der nur Kompatibilitätsänderungen enthält, können deine Reviewer vertrauen.
Die Breaking Changes, die Rector nicht beheben kann
Die gefährlichen Änderungen beim Sprung von 7.4 auf 8.x betreffen das Verhalten. Die Syntax bleibt gültig, der Code läuft, und das Ergebnis ist ein anderes. Kein Codemod kann das beheben, weil das Tool nicht wissen kann, was dein Code gemeint hat. Fünf davon tauchen in fast jedem Projekt auf.
Vergleiche zwischen Strings und Zahlen wurden in 8.0 vernünftiger. Auf 7.4 ist 0 == "email" wahr, weil der String in eine Zahl umgewandelt wird. Auf 8.0 ist es falsch. Code, der Nutzereingaben, Statusfelder oder CSV-Werte lose gegen null vergleicht, kann stillschweigend den anderen Zweig nehmen. Grep in Validierungs- und Importcode nach == gegen numerische Literale und lies jeden Treffer.
Interne Funktionen werfen jetzt Exceptions, statt null zurückzugeben. Auf 7.4 erzeugte die Übergabe von Müll an viele eingebaute Funktionen eine Warnung und einen null- oder false-Rückgabewert, auf den sich dein Code vielleicht stillschweigend verlassen hat. Auf 8.x wirft derselbe Aufruf einen TypeError oder ValueError. Defensiver Code der Form if (!$result) läuft nie, weil die Exception zuerst kommt. Solche Stellen findest du über deinen Error-Tracker, nicht über den Typechecker, und das ist einer der Gründe, warum die CI-Phase unten wichtig ist.
Named Arguments machten in 8.0 Parameternamen zum Teil der API. Wenn du sie einführst, wird das Umbenennen eines Parameters in einer Abhängigkeit für dich zum Breaking Change. Die subtile Variante aus dem Changelog: call_user_func_array mit einem assoziativen Array behandelt String-Keys jetzt als Named Arguments, statt sie zu ignorieren. Code für dynamisches Dispatching, der Argument-Arrays aus Nutzerdaten oder Konfiguration baut, verdient einen genauen Blick.
Match-Ausdrücke sind vollständig. Rector kann ein switch in ein match umwandeln, aber ein switch ohne default fiel früher stillschweigend durch, während ein match bei einem nicht abgedeckten Wert einen UnhandledMatchError wirft. Das ist meist eine Verbesserung, und gleichzeitig eine neue Produktions-Exception in einem Codepfad, der vorher nie geworfen hat. Prüfe jede Umwandlung, bei der das Original keinen Default-Zweig hatte.
Implizite Umwandlung von Float zu Int mit Präzisionsverlust ist seit 8.1 deprecated. Array-Offsets und String-Offsets, die aus Divisionen berechnet werden, sind die übliche Quelle. Noch bricht nichts, aber die Deprecation-Hinweise werden am ersten Tag deine Logs fluten. Behebe die größten Verursacher also vor dem Umschalten statt danach.
Dynamische Properties kommen in 8.2 auf die Liste: Das Zuweisen einer nicht deklarierten Property auf einer Klasse löst eine Deprecation aus. Legacy-Code, der ad hoc Zustand auf Entities ablegt, tut das ständig. Deklariere die Properties, oder setze #[AllowDynamicProperties] als dokumentierte Übergangslösung auf die Klasse.
Eine Teststrategie, die Fehler vor den Nutzern findet
Das sichere Muster ist langweilig: Lass deine bestehende Testsuite in CI gegen beide Versionen laufen und fass die Produktion nicht an, bevor der 8.3-Job eine Weile grün war.
Füge deiner CI-Matrix einen PHP-8.3-Job neben dem 7.4-Job hinzu. Die Produktion bleibt auf 7.4, Deploys laufen normal weiter, und der neue Job darf anfangs einfach fehlschlagen. Seine Fehlerliste ist deine Arbeitsliste. Fixe vorwärts auf dem Hauptbranch und halte jede Änderung mit beiden Versionen kompatibel, was der 7.4-Job automatisch erzwingt. Lass den 8.3-Job mit error_reporting(E_ALL) laufen und wandle Deprecations in Fehlschläge um, denn die Deprecations sind deine Frühwarnung für die Verhaltensänderungen oben.
Sobald beide Jobs grün sind, stell Staging auf 8.3, während die Produktion auf 7.4 bleibt, und lass es ein bis zwei Wochen mit echtem QA-Traffic so laufen. Die Bugs, die dort auftauchen, sind die, die deine Suite nicht abdeckt, was dir auch etwas Nützliches über die Suite verrät.
Wenn deine Abdeckung dünn ist, schreib zuerst Characterization Tests für die kritischen Abläufe: Registrierung, Checkout, der Billing-Webhook, was auch immer die Rechnungen bezahlt. Du hältst das aktuelle Verhalten fest, inklusive aktueller Bugs, damit der Upgrade-Diff etwas hat, wogegen er geprüft werden kann. Das ist der größte einzelne Faktor im Zeitplan, und genau hier zahlt sich ein externes Code Review aus, denn zu wissen, welche Abläufe riskant sind, ist der Großteil der Arbeit.
Symfony und Doctrine: die Kompatibilitätsbrücke
Upgrade niemals PHP und das Framework im selben Release. Symfony 5.4 LTS ist die Standardbrücke, weil es auf allem von PHP 7.2.5 bis zum aktuellen 8.x läuft. Die Reihenfolge, die funktioniert:
| Schritt | Symfony | PHP |
|---|---|---|
| Start | 5.4 | 7.4 |
| PHP-Upgrade | 5.4 | 8.3 |
| Framework-Upgrade | 6.4 LTS (braucht PHP 8.1+) | 8.3 |
| Optionaler nächster Schritt | 7.x (braucht PHP 8.2+) | 8.3 |
Wenn du auf Symfony 4.4 oder älter bist, geh zuerst auf 5.4 mit PHP 7.4. Jeder Schritt hat eine Variable, also hat jeder Fehler einen Verdächtigen.
Doctrine ist größtenteils ein Dependency-Update: Aktuelle ORM-2.x-Releases laufen problemlos auf 8.3, und dieselbe composer why-not-Ausgabe sagt dir genau, welche deiner Doctrine-Pakete angehoben werden müssen. Wenn du noch auf DBAL 2 bist, nimm die DBAL-3-Migration in den Dependency-Durchgang mit auf, denn DBAL 2 ist End of Life. Die Umstellung von Annotations auf Attribute hebst du dir für den Modernisierungsschritt danach auf; doctrine/annotations funktioniert auf 8.3 weiterhin, und Rector konvertiert es später in einem Rutsch.
Ausliefern mit Kamal oder Coolify
Wenn du Container deployst, ist der Runtime-Wechsel ein Base-Image-Tag: php:8.3-fpm oder das passende FrankenPHP-Image statt des 7.4-Images. Baue deine PECL-Extensions im neuen Image neu und liefere deine php.ini im Image mit aus, damit Staging und Produktion nicht auseinanderdriften können. Lass opcache an und JIT auf der Standardeinstellung aus; für typische Web-Workloads ändert es wenig und fügt eine Variable hinzu, die du während eines Upgrades nicht haben willst.
Mit Kamal ist das Upgrade ein Image-Tausch mit Rolling Restart, und das vorherige Image bleibt verfügbar, sodass kamal rollback dein Notausgang ist und Sekunden dauert. Mit Coolify zeigst du die Ressource auf das neue Dockerfile oder setzt die neue PHP-Version im Build Pack, deployst zuerst auf eine Staging-Ressource und stellst sicher, dass der Health Check eine Route trifft, die die Datenbank nutzt, statt eines statischen 200. So oder so sollte das Umschalten selbst unspektakulär sein, denn alles Interessante ist Wochen vorher in CI passiert.
Ein realistischer Zeitplan für ein PHP 7.4 auf 8.3 Upgrade
Aus den Projekten, die wir durchgeführt haben, decken drei Größenklassen die meisten Fälle ab. Eine kleine Codebasis unter 50.000 Zeilen mit verlässlicher Testsuite braucht ein bis zwei Wochen neben normaler Feature-Arbeit, und das meiste davon sind Dependency-Updates. Eine mittelgroße Anwendung zwischen 50.000 und 200.000 Zeilen mit gemischter Abdeckung landet bei drei bis sechs Wochen, wobei die CI-Matrix-Phase dominiert. Ein großes Legacy-System darüber hinaus mit wenig Testabdeckung ist ein Aufwand von zwei bis vier Monaten, und die ehrliche Aufschlüsselung ist, dass die meiste Zeit in den Aufbau des Sicherheitsnetzes fließt und nur ein Bruchteil in das Upgrade selbst.
Schlechte Testabdeckung, nicht die Größe der Codebasis, ist der Multiplikator. Wenn dich die Schätzung erschreckt, sagt dir die Codebasis damit, was sie wirklich braucht.
Wenn deine Anwendung noch auf 7.4 läuft und niemand im Team den Sprung verantworten will, ist das genau die Art von Projekt, die wir im Rahmen von Legacy-Code-Optimierung übernehmen: erst ein Assessment, dann ein fester Plan mit den Größenklassen oben, konkret auf deinen Code angewandt, dann das Upgrade selbst mit deinem Team im Loop. Schreib ein paar Zeilen zu deinem Stack an hello@wolf-tech.io oder schau dich auf wolf-tech.io um. Ein Assessment kostet dich einen Nachmittag und ersetzt die Angst durch eine Zahl.

