700K Zeilen Doctrine 1.x migrieren: Schema-First-Pfad zu Doctrine 3 ohne Code-Freeze
Doctrine 1.x erreichte sein End-of-Life im Jahr 2014. Das ist elf Jahre her, und doch laeuft in einer bedeutenden Anzahl europaeischer Mid-Market-PHP-Anwendungen immer noch diese Version - meist vergraben unter einem Geruest aus benutzerdefinierten Patches, handgeschriebenen Query-Buildern und Stammwissen von Entwicklern, die laengst weitergezogen sind. Codebasen, die am laengsten bei Doctrine 1 bleiben, sind in der Regel diejenigen, bei denen es am engsten mit allem anderen verknuepft 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 fuer eine breitere PHP-8- und Symfony-7-Modernisierung, und die Einschraenkung war absolut: Die Feature-Arbeit konnte nicht vier Monate lang stoppen, waehrend die Infrastruktur neu aufgebaut wurde. Das Produktteam hatte Quartalsziele. Die Migration musste drumherum stattfinden.
Was folgt, ist das Playbook: die Schema-First-Introspektionsstrategie, die Dual-ORM-Bridge, die Legacy-Code am Laufen hielt, waehrend neuer Code Doctrine 3 verwendete, der Test-Harness auf Basis von Datenbank-Snapshots und die Cutover-Sequenz, die die Migration tatsaechlich zum Abschluss gebracht hat.
Warum Doctrine 1.x noch existiert (und warum es jetzt ein Haftungsrisiko ist)
Doctrine 1 verwendet das ActiveRecord-Pattern. Entitaeten 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 spaeter 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 Randfaellen, und die nicht gewartete Codebasis hat CVEs angehaeuft, die niemand behebt. Jede Anwendung, die unter der DSGVO Kundendaten verarbeitet - was praktisch jede europaeische B2B-SaaS beschreibt - traegt mit Doctrine 1 im Jahr 2026 ein echtes Compliance-Risiko.
Die praktischen Probleme haeufen sich mit der Zeit. PHP 8.x fuehrte 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 genuegend Polyfills moeglich, 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 durchfuehrt.
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 Entitaeten und iterierst von dort.
In der Praxis scheitert das bei grossen Codebasen erheblich. Nach Jahren manueller Datenbankoperationen, direkt auf der Produktion eingespielter Hotfixes und Entwicklern, die das YAML nicht aktualisierten, wenn sie eine Tabelle aenderten, 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-Entitaeten aus dem YAML zu generieren, und dann die erste Integrationstestsuite gegen Staging laufen liess. 23% der Abfragen lieferten falsche Ergebnisse oder fatale Typfehler - nicht weil die Migration schlecht gemacht worden war, sondern weil die Wahrheitsquelle falsch war.
Die Loesung 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 Entitaeten 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 Fremdschlussel so repraesentiert, wie Doctrine 3 sie versteht.- Einen benutzerdefinierten Generator (rund 400 Zeilen PHP) ausfuehren, der das
Schemadurchlaeuft und Entity-Klassen mit#[Entity],#[Table],#[Column]und#[ManyToOne]/#[OneToMany]-Attributen ausgibt, die aus Fremdschlussel-Constraints abgeleitet werden. - Die Entitaeten in einen
src/Entity/Legacy/-Namespace ausgeben, getrennt von neuen Entitaeten, die das Team parallel aufbaute.
Der entscheidende Unterschied zur YAML-Konvertierung: Jeder Property-Typ wird aus der tatsaechlichen Spaltendefinition abgeleitet - nicht aus einer YAML-Datei, die moeglicherweise eine type: integer-Spalte beschreibt, die die Datenbank tatsaechlich als VARCHAR(255) speichert, weil jemand eine Datentypanderung 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 Fremdschlussel, 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 Entitaeten 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 Loesung. Die Idee ist unkompliziert: Fuehre 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 ueber separate Verbindungsobjekte mit separater Konfiguration. Doctrine 1 verwendet seinen eigenen Doctrine_Manager-Singleton wie immer; Doctrine 3 verwendet einen standardmaessigen Symfony-EntityManager, der als Service registriert ist. Sie teilen keinen Verbindungsstatus.
Write-Koordination. Das groesste 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 zurueckliest, bevor die Transaktion committed, siehst du veraltete Daten. Wir haben das adressiert, indem wir alle Doctrine-3-Entitaeten 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 hinzugefuegt, die aktiv portiert wurde. Neuer Code pruefte dieses Flag, bevor er entschied, welches ORM fuer einen bestimmten Datensatz verwendet werden soll. Das ermoeglichte es uns, Daten auf Zeilenebene statt auf Tabellenebene zu migrieren - hochwertige Kundenkonten konnten zuerst zu Doctrine-3-Entitaeten verschoben werden, waehrend aeltere ruhende Konten in Batch-Jobs folgten.
Die Bridge fuegte fuer die belebtesten Controller etwa 12 Millisekunden Overhead pro Request hinzu, aufgrund der Flag-Pruefung und der expliziten Cache-Invalidierung. Dieser Overhead war fuer ein viermonatiges Fenster akzeptabel und wurde beim Cutover vollstaendig entfernt.
Schritt 3: Der Snapshot-Test-Harness
Die Migration eines ORM in einer Codebasis mit partieller Testabdeckung erfordert ein Sicherheitsnetz, das nicht davon abhaengt, gute Unit-Tests fuer den zu migrierenden Code zu haben. Datenbank-Snapshot-Fixtures lieferten dieses Sicherheitsnetz.
Der Ansatz: Bevor irgendein Controller oder Service beruehrt wurde, schrieben wir ein Script, das den vollstaendigen 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.
Waehrend der Migration replizierte die Testsuite, anstatt neue Unit-Tests zu schreiben (was erfordert haette, 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 zurueckgab 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-Fideli taets-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 ueber vier Monate, wobei jede Phase einen Anwendungsausschnitt nach Domaene 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-Entitaeten, fuehrten die Snapshot-Tests aus, deployten nach Staging und hielten zwei Wochen lang inne, waehrend sowohl der Legacy- als auch der neue Code liefen und ihre Ausgaben in Produktionsprotokollen verglichen wurden.
Monat 2: Nutzer- und Account-Management. Mittlere Komplexitaet. Die User-, Organisation- und Subscription-Aggregate waren die am gruendlichsten unit-getesteten Teile der Legacy-Codebasis, was die Regressionserkennung erleichterte. Das migration_state-Flag der Dual-ORM-Bridge ermoeglichte es uns, Konten schrittweise zu Doctrine 3 zu migrieren, beginnend mit internen Testkonten und erweiternd auf die Produktion ueber drei Wochen.
Monat 3: Abrechnung und Rechnungsstellung. Die sensibelste Domaene. Wir fuehrten eine erweiterte Parallelperiode von sechs Wochen statt zwei durch, mit einem taeglichen Abstimmungs-Job, der Doctrine-1- und Doctrine-3-Rechnungsgesamtbetraege fuer 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 zurueckgaben).
Monat 4: Core-Produktmodul und Doctrine-1-Entfernung. Der letzte Stapel umfasste die primaere 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-Abhaengigkeit, die Bridge-Schicht und die migration_state-Spalten aus jeder Tabelle zu entfernen.
Das Gesamtergebnis: Doctrine 1 aus einer 700K-Zeilen-Produktions-Codebasis ueber vier Monate entfernt, ohne Feature-Freeze, ohne Produktionsvorfall, der einen Rollback erforderte, und mit einer Testsuite, die am Ende deutlich groesser war als zu Beginn.
Was wir anders machen wuerden
Snapshot-Erfassung frueher starten. Wir begannen mit der Erfassung von Query-Snapshots zwei Wochen in Monat eins, nachdem die Introspektionsphase abgeschlossen war. Im Nachhinein haetten 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 guenstiger wird das Sicherheitsnetz.
Den Abweichungs-Backlog-Triage automatisieren. Die 847 Eintraege der DISCREPANCIES.md-Datei wurden manuell von einem Entwickler ueber drei Tage triagiert. Ein einfaches Klassifikationsscript, das Abweichungen nach Tabellenzugriffshaeufigkeit (abgeleitet aus Anwendungsprotokollen) sortiert, haette die Triage-Zeit auf einen halben Tag reduziert und sichergestellt, dass die Tabellen mit dem hoechsten Traffic zuerst adressiert werden.
Doctrine 2 als Zwischenschritt fuer Teams mit weniger Migrationserfahrung in Betracht ziehen. Wir sind direkt von Doctrine 1 zu Doctrine 3 gegangen, was fuer eine Greenfield-Migration die richtige Wahl ist, aber das Team erfordert, zwei grosse Versionsaenderungen gleichzeitig zu absorbieren. Fuer Teams, die weniger mit der Doctrine-3-Identity-Map und den Typ-Koerzions-Aenderungen vertraut sind, reduziert ein Doctrine-2-Zwischenschritt den Lernaufwand - auf Kosten der doppelten Durchfuehrung der Migration.
Erste Schritte
Wenn deine Codebasis noch auf Doctrine 1.x laeuft 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 auszufuehren 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 Schaetzung der Migrationskomplexitaet.
Wenn du ein erfahrenes Team benotigst, um die Migration durchzufuehren - oder um deinen Ansatz zu ueberpruefen, 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 durchgefuehrt und koennen den Aufwand ehrlich einschaetzen, basierend auf dem, was deine Schema-Introspection tatsaechlich zeigt, statt auf optimistischen Schaetzungen aus YAML-Dateien, die moeglicherweise nicht mit deiner Datenbank uebereinstimmen.
Melde dich bei hello@wolf-tech.io - wir beginnen gerne mit einer unverbindlichen Schema-Ueberpruefung, wenn das hilfreich ist.

