Legacy-PHP-Refactoring: Der Schritt-für-Schritt-Prozess zur Modernisierung einer Symfony-2/3-Codebasis ohne Rewrite
Die Symfony-2.8-Anwendung, die Dein Team vor sechs Jahren geerbt hat, war für ihre Zeit vermutlich solide gebaut. Aber sie läuft jetzt auf PHP 7.2, hat keine Typdeklarationen, nutzt das Bundle-System aus der Zeit vor Flex, und Deine besten Entwickler verbringen mehr Zeit mit Deprecation-Warnungen als mit neuen Features.
Du weißt, dass sich etwas ändern muss. Die Frage ist wie - ohne das Geschäft zwölf Monate anzuhalten und alles auf einen Rewrite zu setzen, der womöglich nie fertig wird.
Legacy-PHP-Refactoring in Phasen ist die Antwort. Kein Rewrite. Kein Freeze. Eine disziplinierte, inkrementelle Modernisierung, bei der die Anwendung durchgehend in Produktion bleibt.
Das ist der Fünf-Phasen-Prozess, den Wolf-Tech einsetzt, wenn Kunden mit Symfony-2- und Symfony-3-Codebasen zu uns kommen, die Symfony 7 und PHP 8.3 erreichen sollen.
Warum Rewrites fast immer scheitern
Vor dem Prozess: ein Wort zur Rewrite-Falle.
Joel Spolsky hat darüber schon 2000 geschrieben, und nichts hat sich geändert. Ein kompletter Rewrite wirft das angesammelte Wissen weg, das in jedem hässlichen Conditional Deiner Codebasis steckt. Dieser if ($user->getCountry() === 'DE' && $cart->hasTaxExemption())-Zweig existiert, weil 2017 ein deutscher Steuerprüfer auftauchte und die korrekte Lösung des Edge Case drei Sprints gekostet hat. Ein Greenfield-Rewrite wird diesen Bug reproduzieren.
Rewrites haben außerdem eine unangenehme Eigenschaft: Sie dauern doppelt so lange wie geschätzt, liefern 70 % der Features und verlangen, dass das alte System parallel weiterläuft - was bedeutet, dass Du zwei Codebasen gleichzeitig wartest.
Die Alternative ist Legacy-PHP-Refactoring: den bestehenden Code voranbringen, ein gut getesteter Schritt nach dem anderen.
Phase 1: Baseline-Messung
Du kannst nicht verbessern, was Du nicht gemessen hast. Bevor Du eine einzige Zeile Anwendungslogik anfasst, etabliere eine Baseline, die jede folgende Entscheidung leitet.
PHPStan auf Level 0. Installiere PHPStan und lasse es auf Level 0 laufen, um eine Rohzahl typbezogener Verstöße zu bekommen. Für eine typische Symfony-2.8-Codebasis mit 60-80K Zeilen sind 1.500-5.000 Fehler zu erwarten. Keine Panik. Schreib die Zahl auf. Das ist Dein Startpunkt.
Testabdeckungsbericht. Lasse Deine Testsuite laufen - falls eine existiert - und erzeuge mit Xdebug oder PCOV einen HTML-Coverage-Report. Bei den meisten Codebasen in diesem Zustand liegt die Abdeckung zwischen 15 % und 35 %. Konzentriere Dich weniger auf den Prozentwert und mehr darauf, welche Verzeichnisse ungetestet sind. Controller und Domain-Services ohne jede Abdeckung sind Deine risikoreichsten Refactoring-Ziele.
Hotspot-Karte der zyklomatischen Komplexität. Nutze PHP Mess Detector (phpmd) oder den Komplexitäts-Sniff von PHP_CodeSniffer, um Funktionen mit zyklomatischer Komplexität über 15 zu identifizieren. Das sind Deine risikoreichsten Dateien. Drucke die Top 20 aus - sie verlangen später besondere Behandlung.
Rector-Upgrade-Schätzung. Lasse rector process src --dry-run --config rector-upgrade-estimate.php mit einer Konfiguration laufen, die auf Deine Ziel-PHP- und Symfony-Versionen zielt. Zähle die Änderungen, die Rector automatisieren kann. Ziehe diese Zahl von den Gesamtverstößen ab, um den Umfang der manuellen Arbeit abzuschätzen.
Diese Baseline kostet bei einer mittelgroßen Codebasis einen halben Tag und spart später Wochen fehlgeleiteter Arbeit.
Phase 2: Den Symfony-Upgrade-Pfad planen
Der Sprung von Symfony 2.8 direkt auf Symfony 7 in einem Schritt ist technisch möglich und praktisch katastrophal. Der richtige Ansatz ist ein Migrationspfad über mehrere Versionen entlang der Long-Term-Support-Releases (LTS).
Der empfohlene Pfad: 2.8 → 3.4 LTS → 4.4 LTS → 5.4 LTS → 6.4 LTS → 7.2.
Jeder LTS-zu-LTS-Sprung ist so dimensioniert, dass er in einen einzelnen Sprint oder ein zweiwöchiges Arbeitspaket passt. Das zählt, weil jeder Sprung eine vorhersehbare Deprecation-Oberfläche hat - Symfonys UPGRADE-Notizen dokumentieren exakt, was sich geändert hat.
Composer- und Flex-Migration. Symfony 2 und 3 nutzen das Bundle-Registrierungsmuster aus der Zeit vor Flex: AppKernel.php registriert jedes Bundle manuell. Ab Symfony 4 automatisiert Symfony Flex die Bundle-Verdrahtung über Recipes. Die Migration vom manuellen AppKernel.php zu Flex ist einer der zeitaufwendigeren Schritte, zahlt sich aber sofort in weniger Konfigurations-Boilerplate aus. Erledige sie während des Sprungs 3.4 → 4.4.
PHP-Kompatibilitätsmatrix. Jede Symfony-Version hat PHP-Mindestanforderungen. Symfony 5.4 verlangt PHP 7.4+. Symfony 6.4 verlangt PHP 8.1+. Symfony 7.x verlangt PHP 8.2+. Das heißt: Das PHP-Upgrade muss parallel zum Symfony-Upgrade laufen - Symfony allein geht nicht. Plane mindestens zwei PHP-Major-Sprünge ein, wenn Du bei PHP 7.2 startest.
Priorität der Deprecation-Auflösung. Während jedes Sprungs emittiert symfony/deprecation-contracts Deprecation-Hinweise für API-Nutzung, die in der nächsten Major-Version entfernt wird. Nutze SYMFONY_DEPRECATIONS_HELPER=max[self]=0 in Deiner Testsuite, um eigene Deprecations als Testfehler zu behandeln. Das verwandelt einen passiven Warnungsstrom in ein aktives Gate, das Dich erst weitergehen lässt, wenn die Deprecations beseitigt sind.
Erwäge einen dedizierten Service für Legacy-Code-Modernisierung, wenn die Upgrade-Oberfläche größer als zwei Sprints ist - die Kosten einer falschen Versionssequenzierung potenzieren sich bei jedem weiteren Sprung.
Phase 3: Testen, bevor Du refaktorierst
Das ist die Phase, die die meisten Teams überspringen, und der Grund, warum die meisten Legacy-PHP-Refactoring-Projekte auf halbem Weg scheitern.
Die Kernregel: Refaktoriere keinen Code, für den Du keinen Charakterisierungstest hast.
Ein Charakterisierungstest testet nicht, was der Code tun sollte. Er testet, was er aktuell tut - Bugs eingeschlossen. Schreibe einen für jede Klasse, die Du ändern willst, bevor Du sie änderst. Wenn die Tests vor und nach Deiner Änderung bestehen, hast Du kein bestehendes Verhalten gebrochen.
Approval Testing für untestbaren Code. Für Methoden, bei denen Unit-Tests unpraktikabel sind - Legacy-Controller mit sieben Abhängigkeiten, God Classes mit 2.000 Zeilen -, nutze Approval Testing. Approval Testing erfasst die vollständige Ausgabe einer Methode (HTML, JSON, serialisiertes Objekt) in einer "Received"-Datei und vergleicht sie mit einer freigegebenen Baseline. Elegant ist das nicht, aber es gibt Dir ein Sicherheitsnetz, dessen Aufbau 30 Minuten kostet.
Der Strangler-Fig-Ansatz für untestbare Klassen. Für Klassen, bei denen selbst Approval Testing unpraktikabel ist - typischerweise Klassen mit hartkodierten Datenbankaufrufen, global-Variablen oder unauflösbaren statischen Abhängigkeiten -, wende das Strangler-Fig-Muster an. Erstelle eine neue Implementierung neben der alten, leite einen kleinen Prozentsatz des Traffics auf den neuen Code, verifiziere die Korrektheit mit Vergleichs-Logging und erweitere das Routing inkrementell, bis die alte Klasse gelöscht werden kann. So geht Wolf-Tech Legacy-Code-Modernisierung an: inkrementell, mit Verifikation bei jedem Schritt, ohne das Geschäft anzuhalten.
Phase 4: Automatisiertes Refactoring mit Rector
Sobald Tests die Klassen abdecken, die Du ändern willst, übernimmt Rector die Schwerarbeit.
Rector ist ein AST-basiertes PHP-Refactoring-Tool. Es parst Deinen Quellcode in einen Syntaxbaum, wendet Transformationsregeln an und schreibt syntaktisch korrektes PHP zurück. Es rät nicht. Es wendet deterministische Regeln an.
Die Regeln, die 80 % der mechanischen Änderungen abdecken. Für eine Migration von Symfony 2/3 zu PHP 8 sind das die wertvollsten Rector-Regeln:
TypedPropertyRector- ergänzt typisierte Property-Deklarationen aus PHPDoc-AnnotationenAddArrayReturnDocTypeRector- inferiert Rückgabetypen für Array-liefernde MethodenStringClassNameToClassConstantRector- ersetzt'App\Entity\User'-Strings durchUser::classAnnotationToAttributeRector- konvertiert Doctrine- und Symfony-Annotationen (@ORM\Column) zu PHP-8-Attributen (#[ORM\Column])ClassPropertyAssignToConstructorPromotionRector- konvertiert Konstruktor-Property-Zuweisungen zu Constructor PromotionNullToStrictStringFuncCallArgRector- behebt Null-Koersion in String-Funktionen, die in PHP 8.1 deprecated wurdeReturnTypeFromStrictTypedCallRector- inferiert Rückgabetypen aus Aufrufen strikt typisierter MethodenRemoveUselessParamTagRectorundRemoveUselessReturnTagRector- räumt redundantes PHPDoc auf
Lasse Rector über jeweils ein Verzeichnis laufen, prüfe den Diff, lasse Deine Testsuite laufen, committe. Behandle jeden Rector-Durchlauf als eigenen Commit mit klarer Message (refactor: apply Rector typed properties to Domain/). Das hält die Git-Historie bisektierbar.
Regeln, die menschliche Prüfung brauchen. Nicht alles, was Rector ändern kann, sollte blind angewendet werden. Regeln, die Geschäftslogik verändern, Exception-Typen ändern oder Konstruktorargumente umsortieren, brauchen einen Menschen in der Schleife. Baue eine rector-human-review.php-Konfiguration, die diese Regeln vom automatisierten Durchlauf ausschließt und dokumentiert, warum.
Phase 5: Cutover und Monitoring
Die letzte Phase - Produktionstraffic auf die modernisierte Codebasis umstellen - ist der Punkt, an dem Legacy-PHP-Refactoring-Projekte am häufigsten straucheln. Deploye nicht alles auf einmal.
Feature-Flag-basierter Parallelbetrieb. Lasse vor dem vollständigen Cutover den alten und den neuen Codepfad gleichzeitig in Produktion für ausgewählten Traffic laufen. Leite 5 % der Requests durch den modernisierten Service, vergleiche die Ausgaben und logge Abweichungen. Das ist im Prinzip identisch mit dem Dry-Run einer Datenbankmigration. Es deckt Edge Cases auf, die Deine Charakterisierungstests nicht erfasst haben.
Rollback-Stolperdrähte. Definiere messbare Rollback-Kriterien vor dem Deploy: Fehlerrate über X %, p99-Latenz über Y ms, Zahlungsfehler über Z pro Minute. Kodiere sie als Alerts in Deinem Monitoring-Stack (Datadog, Grafana oder New Relic). Löst ein Stolperdraht innerhalb von 24 Stunden nach einem Cutover aus, rolle sofort zurück, ohne Diskussion. Untersuchen kannst Du sicher, während der alte Code läuft.
Post-Launch-Monitoring-Dashboard. Baue ein leichtgewichtiges Dashboard, das nach jedem großen Sprung 30 Tage lang vier Signale verfolgt: Fehlerrate pro Endpoint, Anzahl langsamer Queries, Anzahl der Deprecation-Hinweise (sollte nach jedem Sprung auf null fallen) und PHPStan-Level (sollte stetig steigen). Das PHPStan-Level ist als sichtbarer Fortschrittsindikator für nicht-technische Stakeholder besonders nützlich.
Beispiel aus der Praxis: 80K Zeilen, Symfony 2.8 zu Symfony 7, sechs Monate
Ein Wolf-Tech-Kunde kam mit einer SaaS-Plattform mit 80.000 Zeilen auf Symfony 2.8 und PHP 7.2 zu uns. Das Produkt war seit neun Jahren in Produktion, verarbeitete Transaktionen in Millionenhöhe pro Jahr und hatte 18 % Testabdeckung. Ein früheres Team hatte einen vollständigen Rewrite versucht und nach vier Monaten mit einem halbfertigen Parallelsystem aufgehört, das still beerdigt wurde.
Unser Ansatz:
- Monat 1: Baseline-Messung, PHPStan-Level-0-Audit (3.247 Fehler), Charakterisierungstests für die 40 komplexesten Klassen, Rector-Dry-Run-Analyse.
- Monat 2: Upgrade PHP 7.2 → 7.4 → 8.0, Sprung Symfony 2.8 → 3.4, Flex-Migration, Deprecation-Bereinigung.
- Monat 3: Symfony 3.4 → 4.4, Annotation-zu-Attribut-Rector-Durchlauf über alle Doctrine-Entities, Constructor Promotion im gesamten Service-Layer.
- Monat 4: Symfony 4.4 → 5.4, PHP 8.0 → 8.1, Named Arguments schrittweise eingeführt, Testabdeckung auf 54 % erhöht.
- Monat 5: Symfony 5.4 → 6.4, PHP 8.1 → 8.2, vollständig typisierte Properties, PHPStan auf Level 5 angehoben.
- Monat 6: Symfony 6.4 → 7.2, PHP 8.2 → 8.3, PHPStan Level 7, finaler Parallelbetrieb-Cutover.
Ergebnis: Die Anwendung läuft jetzt auf aktuellen, unterstützten PHP- und Symfony-Versionen, die Deployment-Pipeline fiel von 18 auf 6 Minuten, und die Team-Velocity bei neuen Features stieg, weil Entwickler keine Sprint-Kapazität mehr mit Deprecation-Feuerwehr verbrennen. Die vollständige Codebasis-Historie blieb erhalten - jeder Edge Case, jede Geschäftsregel, jedes deutsche Steuerprüfungs-Conditional - intakt.
Wann anfangen
Die Antwort lautet: früher, als sich angenehm anfühlt.
Symfony 3.4 erreichte im November 2021 sein End-of-Life. Symfony 4.4 LTS erreichte es im November 2023. Läuft Deine Anwendung noch auf einer dieser Versionen, erhältst Du keine Sicherheitspatches mehr. Jeder Tag ohne Modernisierungsplan ist eine wachsende Belastung.
Legacy-PHP-Refactoring verlangt keinen Budget-Freeze und keine Pause der Produkt-Roadmap. Der hier beschriebene Fünf-Phasen-Prozess läuft parallel zur Feature-Entwicklung, wobei jede Phase einen stabilen, releasefähigen Zustand produziert. Die erste Phase - die Baseline-Messung - kostet einen Arbeitstag und liefert ein klares Bild dessen, womit Du es zu tun hast.
Wenn Du eine zweite Meinung zum Refactoring-Umfang Deiner Codebasis willst, bevor Du Dich auf einen Plan festlegst, bietet Wolf-Tech ein strukturiertes Code-Assessment, das eine priorisierte Modernisierungs-Roadmap liefert. Melde Dich unter hello@wolf-tech.io oder besuche wolf-tech.io, um das Gespräch zu starten.

