700K Zeilen Doctrine 1.x migrieren: Schema-First-Pfad zu Doctrine 3 ohne Code-Freeze

Sandor Farkas
Gründer & Lead Developer
Experte für Softwareentwicklung und Legacy-Code-Optimierung
LinkedInDoctrine 1.x erreichte sein End-of-Life im Jahr 2014. Das ist elf Jahre her, und doch läuft in einer bedeutenden Anzahl europäischer Mid-Market-PHP-Anwendungen immer noch diese Version - meist vergraben unter einem Gerüst aus benutzerdefinierten Patches, handgeschriebenen Query-Buildern und Stammwissen von Entwicklern, die längst weitergezogen sind. Codebasen, die am längsten bei Doctrine 1 bleiben, sind in der Regel diejenigen, bei denen es am engsten mit allem anderen verknüpft ist: ActiveRecord-Muster, die durch 700.000 Zeilen Controller, Service-Klassen und Report-Generatoren gewoben sind, die nie als portierbar gedacht waren.
Dieser Post dokumentiert den Ansatz, den wir bei einem solchen Projekt verwendet haben. Der Kunde war ein deutsches B2B-SaaS-Unternehmen - Mid-Market, achtstelliger Jahresumsatz, eine PHP-Codebasis, die seit 2009 in aktiver Entwicklung war. Die Doctrine-1-Migration war die Vorbedingung für eine breitere PHP-8- und Symfony-7-Modernisierung, und die Einschränkung war absolut: Die Feature-Arbeit konnte nicht vier Monate lang stoppen, während die Infrastruktur neu aufgebaut wurde. Das Produktteam hatte Quartalsziele. Die Migration musste drumherum stattfinden, ohne Code-Freeze und auf einem Schema-First-Pfad.
Was folgt, ist das Playbook: die Schema-First-Introspektionsstrategie, die Dual-ORM-Bridge, die Legacy-Code am Laufen hielt, während neuer Code Doctrine 3 verwendete, der Test-Harness auf Basis von Datenbank-Snapshots und die Cutover-Sequenz, die die Migration tatsächlich zum Abschluss gebracht hat.
Warum Doctrine 1.x noch existiert (und warum es jetzt ein Haftungsrisiko ist)
Doctrine 1 verwendet das ActiveRecord-Pattern. Entitäten erweitern Doctrine_Record, und das ORM ist Teil des Objekts selbst und kein separater Unit of Work. Das machte es 2008 sehr einfach, Datenzugriffscode zu schreiben, und sehr schwer, ein Jahrzehnt später davon wegzumigrieren - jede Modellmethode, die $this->save() aufruft, ist direkt mit der ORM-Schicht gekoppelt.
Die Sicherheitsimplikationen sind inzwischen gravierend. Doctrine 1 entstand vor modernen PHP-Sicherheitspraktiken rund um parametrisierte Abfragen in Randfällen, und die nicht gewartete Codebasis hat CVEs angehäuft, die niemand behebt. Jede Anwendung, die unter der DSGVO Kundendaten verarbeitet - was praktisch jede europäische B2B-SaaS beschreibt - trägt mit Doctrine 1 im Jahr 2026 ein echtes Compliance-Risiko.
Die praktischen Probleme häufen sich mit der Zeit. PHP 8.x führte Constructor Promotion, Named Arguments und Fibers ein, und die Doctrine-1-Codebasis ist mit mehreren davon auf der Ebene interner __get/__set-Magic-Methods inkompatibel. PHP 8.3 mit Doctrine 1 zu betreiben ist mit genügend Polyfills möglich, aber jedes PHP-Minor-Release verengt das Fenster weiter.
Die Migration ist keine Option. Die Frage ist, wie man sie ohne einen viermonatigen Feature-Freeze durchführt.
Das Kernproblem mit YAML-First-Migrationsstrategien
Der Standardrat zur Doctrine-1-Migration besteht darin, mit den YAML-Schema-Dateien (schema.yml) zu beginnen und sie in Doctrine-3-Entity-Attribute oder -Annotationen zu konvertieren. Theoretisch liest du das YAML, generierst die Entitäten und iterierst von dort.
In der Praxis scheitert das bei großen Codebasen erheblich. Nach Jahren manueller Datenbankoperationen, direkt auf der Produktion eingespielter Hotfixes und Entwicklern, die das YAML nicht aktualisierten, wenn sie eine Tabelle änderten, sind die schema.yml-Dateien in einer 700K-Zeilen-Codebasis keine genaue Beschreibung der Datenbank. Sie beschreiben die Datenbank, wie sie zu einem bestimmten Zeitpunkt in der Vergangenheit gedacht war, mit Abweichungen, die niemand systematisch nachverfolgt hat.
Wir entdeckten das, als ein Junior-Entwickler im Client-Team drei Wochen damit verbrachte, Doctrine-3-Entitäten aus dem YAML zu generieren, und dann die erste Integrationstestsuite gegen Staging laufen ließ. 23% der Abfragen lieferten falsche Ergebnisse oder fatale Typfehler - nicht weil die Migration schlecht gemacht worden war, sondern weil die Wahrheitsquelle falsch war.
Die Lösung besteht darin, das YAML als autoritative Quelle zu verwerfen und stattdessen das Live-Datenbankschema als Ground Truth zu behandeln.
Schritt 1: Schema-First-Introspection
Statt YAML in Entitäten zu konvertieren, haben wir die Produktionsdatenbank direkt mit Doctrines SchemaManager und einem benutzerdefinierten Generator-Script introspektiert. Der Ablauf:
- Einen Doctrine-3-
Connectionauf ein Read-Only-Replikat der Produktion richten. $connection->createSchemaManager()->introspectSchema()aufrufen, um einSchema-Objekt zu erhalten, das jede Tabelle, Spalte, Index und jeden Fremdschlüssel so repräsentiert, wie Doctrine 3 sie versteht.- Einen benutzerdefinierten Generator (rund 400 Zeilen PHP) ausführen, der das
Schemadurchläuft und Entity-Klassen mit#[Entity],#[Table],#[Column]und#[ManyToOne]/#[OneToMany]-Attributen ausgibt, die aus Fremdschlüssel-Constraints abgeleitet werden. - Die Entitäten in einen
src/Entity/Legacy/-Namespace ausgeben, getrennt von neuen Entitäten, die das Team parallel aufbaute.
Der entscheidende Unterschied zur YAML-Konvertierung: Jeder Property-Typ wird aus der tatsächlichen Spaltendefinition abgeleitet - nicht aus einer YAML-Datei, die möglicherweise eine type: integer-Spalte beschreibt, die die Datenbank tatsächlich als VARCHAR(255) speichert, weil jemand eine Datentypänderung ohne Migration vorgenommen hat.
Der Generator gab auch eine DISCREPANCIES.md-Datei aus, die jede Stelle auflistete, an der das abgeleitete Schema mit dem bestehenden YAML in Konflikt stand - 847 Abweichungen in dieser Codebasis, darunter 14 fehlende Tabellen, 62 Spalten mit falschen Typen und 9 Fremdschlüssel, die in der Datenbank existierten, aber im YAML fehlten. Dieses Dokument wurde zum Backlog-Dokument des Migrationsteams.
Schritt 2: Die Dual-ORM-Bridge
Die korrekten Entitäten zu generieren ist der einfache Teil. Der schwierige Teil ist, dass 700.000 Zeilen Produktionscode Doctrine_Record-Subklassen direkt referenzieren, und du kannst das nicht alles vor der Migrationsfrist umschreiben.
Die Dual-ORM-Bridge ist die Lösung. Die Idee ist unkompliziert: Führe Doctrine 1 und Doctrine 3 gleichzeitig innerhalb derselben Symfony-Anwendung aus, mit einer Routing-Schicht, die bestimmt, welches ORM einen bestimmten Datenzugriffsaufruf behandelt.
Die Implementierung hat drei Komponenten.
Verbindungsisolation. Beide ORMs verbinden sich mit derselben Datenbank, aber über separate Verbindungsobjekte mit separater Konfiguration. Doctrine 1 verwendet seinen eigenen Doctrine_Manager-Singleton wie immer; Doctrine 3 verwendet einen standardmäßigen Symfony-EntityManager, der als Service registriert ist. Sie teilen keinen Verbindungsstatus.
Write-Koordination. Das größte Risiko bei einer Dual-ORM-Einrichtung ist, dass beide ORMs ihre eigenen Identity-Maps und Unit-of-Work-Caches pflegen. Wenn Doctrine 1 einen Datensatz schreibt und Doctrine 3 ihn aus dem Cache zurückliest, bevor die Transaktion committed, siehst du veraltete Daten. Wir haben das adressiert, indem wir alle Doctrine-3-Entitäten in der Bridge-Periode als nicht-cachebar auf der Second-Level-Cache-Schicht markiert haben und indem wir immer den Doctrine-1-Connection flushen, bevor ein Doctrine-3-Read in derselben Request-Verarbeitung stattfindet.
Ein Migrations-Flag pro Aggregatwurzel. Wir haben eine migration_state-Spalte (mit den Werten legacy, in_progress, migrated) zu jeder Tabelle hinzugefügt, die aktiv portiert wurde. Neuer Code prüfte dieses Flag, bevor er entschied, welches ORM für einen bestimmten Datensatz verwendet werden soll. Das ermöglichte es uns, Daten auf Zeilenebene statt auf Tabellenebene zu migrieren - hochwertige Kundenkonten konnten zuerst zu Doctrine-3-Entitäten verschoben werden, während ältere ruhende Konten in Batch-Jobs folgten.
Die Bridge fügte für die belebtesten Controller etwa 12 Millisekunden Overhead pro Request hinzu, aufgrund der Flag-Prüfung und der expliziten Cache-Invalidierung. Dieser Overhead war für ein viermonatiges Fenster akzeptabel und wurde beim Cutover vollständig entfernt.
Schritt 3: Der Snapshot-Test-Harness
Die Migration eines ORM in einer Codebasis mit partieller Testabdeckung erfordert ein Sicherheitsnetz, das nicht davon abhängt, gute Unit-Tests für den zu migrierenden Code zu haben. Datenbank-Snapshot-Fixtures lieferten dieses Sicherheitsnetz.
Der Ansatz: Bevor irgendein Controller oder Service berührt wurde, schrieben wir ein Script, das den vollständigen Satz an SQL-Abfragen, die der zu migrierende Code gegen Staging ausgab, ausgab, die Ergebnis-Sets als JSON-Fixtures erfasste und sie in tests/snapshots/ speicherte. Jede Snapshot-Datei enthielt die Abfrage, die Parameter und das erwartete Ergebnis-Set.
Während der Migration replizierte die Testsuite, anstatt neue Unit-Tests zu schreiben (was erfordert hätte, die Legacy-Business-Logik im Detail zu verstehen), jede erfasste Abfrage gegen die Doctrine-3-Entity-Schicht und verglich die Ergebnis-Sets. Ein bestandener Snapshot-Test bedeutete, dass die Doctrine-3-Implementierung identische Daten zurückgab wie der Legacy-Doctrine-1-Code zuvor.
Dieser Ansatz hat Grenzen - er erkennt Abfrageergebnis-Regressionen, aber keine Business-Logic-Bugs, die vor der Migration existierten. Wir haben dies mit dem Kunden explizit kommuniziert. Die Snapshot-Tests waren eine Migrations-Fidelitäts-Garantie, keine umfassende funktionale Testsuite. Der Aufbau dieser Suite war ein separater Workstream, der parallel lief.
Schritt 4: Die Cutover-Sequenz
Die Migration verlief in vier Phasen über vier Monate, wobei jede Phase einen Anwendungsausschnitt nach Domäne statt nach technischer Schicht adressierte.
Monat 1: Reporting-Modul. Leselastig, keine Schreibzugriffe aus nutzerseitigen Flows, isoliert vom Transaktionskern. Geringes Risiko. Wir migrierten 14 Report-Generatoren zu Doctrine-3-Entitäten, führten die Snapshot-Tests aus, deployten nach Staging und hielten zwei Wochen lang inne, während sowohl der Legacy- als auch der neue Code liefen und ihre Ausgaben in Produktionsprotokollen verglichen wurden.
Monat 2: Nutzer- und Account-Management. Mittlere Komplexität. Die User-, Organisation- und Subscription-Aggregate waren die am gründlichsten unit-getesteten Teile der Legacy-Codebasis, was die Regressionserkennung erleichterte. Das migration_state-Flag der Dual-ORM-Bridge ermöglichte es uns, Konten schrittweise zu Doctrine 3 zu migrieren, beginnend mit internen Testkonten und erweiternd auf die Produktion über drei Wochen.
Monat 3: Abrechnung und Rechnungsstellung. Die sensibelste Domäne. Wir führten eine erweiterte Parallelperiode von sechs Wochen statt zwei durch, mit einem täglichen Abstimmungs-Job, der Doctrine-1- und Doctrine-3-Rechnungsgesamtbeträge für jedes Konto verglich. Nach der ersten Woche wurden null Abweichungen gefunden, nachdem zwei Typ-Koerzions-Bugs behoben worden waren (Dezimalspalten, die in einem Randfall mit null-wertigen Zeilenposten Strings zurückgaben).
Monat 4: Core-Produktmodul und Doctrine-1-Entfernung. Der letzte Stapel umfasste die primäre Workflow-Engine des Produkts - den komplexesten Code in der Codebasis. Zu diesem Zeitpunkt hatte das Team vier Monate Migrationserfahrung, und die Snapshot-Testsuite deckte 340 Abfragemuster ab. Die eigentliche Migration dauerte drei Wochen; die letzte Woche wurde damit verbracht, die Doctrine-1-Abhängigkeit, die Bridge-Schicht und die migration_state-Spalten aus jeder Tabelle zu entfernen.
Das Gesamtergebnis: Doctrine 1 aus einer 700K-Zeilen-Produktions-Codebasis über vier Monate entfernt, ohne Feature-Freeze, ohne Produktionsvorfall, der einen Rollback erforderte, und mit einer Testsuite, die am Ende deutlich größer war als zu Beginn.
Was wir anders machen würden
Snapshot-Erfassung früher starten. Wir begannen mit der Erfassung von Query-Snapshots zwei Wochen in Monat eins, nachdem die Introspektionsphase abgeschlossen war. Im Nachhinein hätten wir am ersten Tag beginnen sollen, an dem wir Lesezugriff auf Staging hatten - noch bevor irgendeinem Migrations-Code geschrieben wurde. Je mehr Basis-Snapshots du hast, desto günstiger wird das Sicherheitsnetz.
Den Abweichungs-Backlog-Triage automatisieren. Die 847 Einträge der DISCREPANCIES.md-Datei wurden manuell von einem Entwickler über drei Tage triagiert. Ein einfaches Klassifikationsscript, das Abweichungen nach Tabellenzugriffshäufigkeit (abgeleitet aus Anwendungsprotokollen) sortiert, hätte die Triage-Zeit auf einen halben Tag reduziert und sichergestellt, dass die Tabellen mit dem höchsten Traffic zuerst adressiert werden.
Doctrine 2 als Zwischenschritt für Teams mit weniger Migrationserfahrung in Betracht ziehen. Wir sind direkt von Doctrine 1 zu Doctrine 3 gegangen, was für eine Greenfield-Migration die richtige Wahl ist, aber das Team erfordert, zwei große Versionsänderungen gleichzeitig zu absorbieren. Für Teams, die weniger mit der Doctrine-3-Identity-Map und den Typ-Koerzions-Änderungen vertraut sind, reduziert ein Doctrine-2-Zwischenschritt den Lernaufwand - auf Kosten der doppelten Durchführung der Migration.
Erste Schritte
Wenn deine Codebasis noch auf Doctrine 1.x läuft und die Migration auf der Roadmap, aber noch nicht gestartet ist, ist das Wertvollste, was du diese Woche tun kannst, die Schema-Introspection gegen ein Staging-Replikat auszuführen und einen Abweichungsbericht zu generieren. Du musst dich nicht auf einen Migrationszeitplan festlegen, um das zu tun. Der Bericht zeigt dir, wie weit das YAML von der Live-Datenbank abgewichen ist, und gibt dir die erste realistische Schätzung der Migrationskomplexität.
Wenn du ein erfahrenes Team benötigst, um die Migration durchzuführen - oder um deinen Ansatz zu überprüfen, bevor du beginnst - spezialisiert sich wolf-tech.io auf genau diese Art von Legacy-PHP-Modernisierung. Wir haben diese Migration an Codebasen von 80K bis 1,2M Zeilen durchgeführt und können den Aufwand ehrlich einschätzen, basierend auf dem, was deine Schema-Introspection tatsächlich zeigt, statt auf optimistischen Schätzungen aus YAML-Dateien, die möglicherweise nicht mit deiner Datenbank übereinstimmen.
Melde dich bei hello@wolf-tech.io - wir beginnen gerne mit einer unverbindlichen Schema-Überprüfung, wenn das hilfreich ist.
