Doctrine ORM 2 auf 3 migrieren: Breaking Changes in Symfony meistern
AI generated
SF
{ }
Symfony · Doctrine ORM 3 · Migration · Modernisierung
Doctrine ORM 2 auf 3 migrieren
Breaking Changes in Symfony-Projekten sicher meistern

Doctrine ORM 3 ersetzt Proxy-Klassen durch Lazy Ghost Objects, aendert EntityManager-Methoden und verschaerft das Mapping fuer Embeddables. Wer diese Breaking Changes vor der Migration kennt und mit einer klaren Checkliste angeht, bringt bestehende Symfony-Projekte ohne Produktionsausfall auf die neue ORM-Version.

20 Min. Lesezeit Lazy Ghost Objects · EntityManager · Mapping Doctrine ORM 3.x · Symfony 7.x

1. Warum die Migration auf Doctrine ORM 3 ansteht

Doctrine ORM 3 ist die erste Major-Version seit vielen Jahren und raeumt konsequent mit Altlasten auf, die sich seit Doctrine ORM 2.0 angesammelt hatten. Fuer Symfony-Projekte, die auf Doctrine als primaeren Persistenz-Layer setzen, bedeutet das: ein direktes composer require doctrine/orm:^3.0 reicht in den seltensten Faellen, weil zentrale Klassen entfernt, umbenannt oder in ihrem Verhalten grundlegend geaendert wurden. Wer die Migration ohne Vorbereitung startet, produziert schnell Hunderte Fehler, die sich kaum priorisieren lassen.

Der Aufwand lohnt sich trotzdem, weil Doctrine ORM 3 spuerbare Verbesserungen bringt: Lazy Ghost Objects ersetzen die bisherigen generierten Proxy-Klassen und reduzieren sowohl den Speicherbedarf als auch die Komplexitaet beim Debugging von Lazy Loading. Gleichzeitig wird das Mapping strenger typisiert, was Fehler bereits zur Entwicklungszeit statt erst zur Laufzeit aufdeckt. Ein Team, das die Migration jetzt plant, vermeidet zudem, spaeter unter Zeitdruck mit einer laenger auslaufenden Doctrine ORM 2 Version zu arbeiten, die keine neuen Features mehr erhaelt.

Dieser Artikel begleitet die Migration von Doctrine ORM 2 auf 3 Schritt fuer Schritt: von den zentralen Breaking Changes ueber die neuen Lazy Ghost Objects bis zu einer praktischen Checkliste, die den Rollout in Staging und Produktion strukturiert.

2. Breaking Changes im Ueberblick

Die groesste sichtbare Aenderung bei der Migration von Doctrine ORM 2 auf 3 betrifft den Namespace und die Struktur der Persistence-Schicht: Teile der gemeinsamen Persistence-Abstraktion wurden in das separate Paket doctrine/persistence ausgelagert, was bestehende use-Statements in vielen Repository- und Listener-Klassen betrifft. Zusaetzlich wurden als deprecated markierte Methoden aus Doctrine ORM 2.x konsequent entfernt, statt sie wie bisher nur mit einer Warnung zu versehen.

Ein weiterer zentraler Bruch betrifft das Mapping: XML- und YAML-Mapping-Treiber wurden zugunsten von PHP-Attributen als einzigem unterstuetzten Format aus dem Kern entfernt. Projekte, die noch Resources/config/doctrine/*.orm.xml-Dateien nutzen, muessen diese vor der Migration vollstaendig auf Attribute umstellen, was in grossen Codebasen der zeitaufwendigste Einzelschritt der gesamten Migration ist.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: EntityManagerInterface still exposed getConnection()
// with a return type that varied across minor versions
$connection = $entityManager->getConnection();

// Doctrine ORM 3.x: explicit, typed access via the Connection interface
use Doctrine\DBAL\Connection;

final class OrderRepository
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function runRawQuery(string $sql): array
    {
        $connection = $this->entityManager->getConnection();
        assert($connection instanceof Connection);

        return $connection->fetchAllAssociative($sql);
    }
}

Wichtig fuer die Planung: Nicht jedes Bundle im eigenen Composer-Baum ist bereits fuer Doctrine ORM 3 vorbereitet. Ein composer why-not doctrine/orm ^3.0 vor dem eigentlichen Upgrade zeigt zuverlaessig, welche Abhaengigkeiten die neue Version noch blockieren, und ist damit der erste praktische Schritt jeder Migration.

3. EntityManager und veraenderte Lifecycle-Methoden

Der EntityManager selbst hat in Doctrine ORM 3 mehrere Methoden verloren, die in ORM 2.x bereits als problematisch galten. merge(), das haeufig zu unerwarteten Seiteneffekten bei detached Entities fuehrte, wurde vollstaendig entfernt. Teams, die dieses Muster noch verwenden, muessen es durch explizites Neuladen der Entity ueber den Identifier und manuelles Uebertragen der geaenderten Felder ersetzen, was zwar mehr Code bedeutet, aber das Verhalten deutlich vorhersehbarer macht.

Auch clear() mit einem Klassennamen als Parameter, das frueher gezielt nur Entities eines bestimmten Typs aus dem Unit of Work entfernte, akzeptiert in Doctrine ORM 3 keine Argumente mehr und leert immer den gesamten Identity Map. Fuer Batch-Verarbeitungen, die bisher selektiv einzelne Entity-Typen aus dem Speicher entfernt haben, bedeutet das eine Anpassung der Batch-Groesse oder eine Umstrukturierung in kleinere, komplett isolierte Verarbeitungsschritte.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: merge() re-attached a detached entity
// $merged = $entityManager->merge($detachedOrder);

// Doctrine ORM 3.x: merge() removed, explicit reload instead
final class OrderReattacher
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function reattach(Order $detachedOrder): Order
    {
        $managed = $this->entityManager->find(Order::class, $detachedOrder->getId());

        if ($managed === null) {
            throw new \RuntimeException('Order no longer exists');
        }

        $managed->updateFrom($detachedOrder);

        return $managed;
    }
}

Diese Entfernungen wirken zunaechst wie eine Einschraenkung, sind aber bewusst so gestaltet: merge() war eine haeufige Quelle von schwer nachvollziehbaren Bugs, gerade wenn Entities ueber Serialisierungsgrenzen hinweg zwischen Prozessen ausgetauscht wurden. Die explizite Alternative macht den Datenfluss sichtbar, statt ihn hinter einer scheinbar bequemen Methode zu verstecken.

4. Lazy Ghost Objects statt klassischer Proxy-Klassen

Der technisch groesste Sprung in Doctrine ORM 3 ist der Wechsel von generierten Proxy-Klassen zu PHP-nativen Lazy Ghost Objects, die auf einem seit PHP 8.1 verfuegbaren Reflection-Mechanismus basieren. Frueher generierte Doctrine fuer jede Entity mit Lazy-Loading-Beziehungen eine eigene Proxy-Klasse im Dateisystem, die von der eigentlichen Entity-Klasse erbte und Methode fuer Methode weiterleitete. Diese generierten Klassen mussten bei jeder Aenderung an der Entity neu erzeugt werden und waren eine haeufige Quelle von Cache-Problemen in der Produktion.

Lazy Ghost Objects verzichten vollstaendig auf generierten Code. Stattdessen erzeugt PHP selbst zur Laufzeit ein Objekt der echten Entity-Klasse, dessen Properties erst beim ersten Zugriff initialisiert werden. Fuer den Anwendungscode aendert sich dabei fast nichts: instanceof-Pruefungen funktionieren jetzt zuverlaessiger, weil ein Lazy Ghost Object tatsaechlich eine Instanz der Entity-Klasse ist, statt einer separaten Proxy-Klasse, die sie nur imitiert.


<?php
declare(strict_types=1);

// Doctrine ORM 2.x: instanceof against a generated proxy class often failed
// get_class($order) returned "Proxies\__CG__\App\Entity\Order"
if ($order instanceof Order) {
    // worked in most, but not all, cases due to proxy inheritance quirks
}

// Doctrine ORM 3.x: Lazy Ghost Objects are real instances of the entity class
$order = $entityManager->getReference(Order::class, 42);
var_dump($order instanceof Order);  // always true, no proxy subclass involved
var_dump(get_class($order));        // "App\Entity\Order", not a generated proxy

// Forcing initialization still works the same way
$entityManager->getUnitOfWork()->initializeObject($order);

Fuer Projekte, die bisher explizit gegen generierte Proxy-Klassennamen geprueft haben, etwa in Debug-Ausgaben oder in eigener Serialisierungslogik, ist eine gezielte Suche nach Proxies\\__CG__ im Codebestand vor der Migration Pflicht. Nach dem Umstieg auf Doctrine ORM 3 existieren diese Klassennamen schlicht nicht mehr, und jeder darauf basierende String-Vergleich liefert ein falsches Ergebnis, ohne dass PHP einen Fehler wirft.

5. Typisierte Embeddables und Mapping-Attribute

Mit dem Wegfall von XML- und YAML-Mapping wird das PHP-Attribut-basierte Mapping zum einzigen unterstuetzten Weg, Entities zu beschreiben. Fuer Projekte, die bereits auf Attribute umgestiegen waren, aendert sich wenig. Fuer Projekte mit historisch gewachsenem XML-Mapping ist die vollstaendige Konvertierung der aufwendigste Teil der gesamten Migration, weil jede Mapping-Datei manuell oder mit einem Konvertierungsskript in Attribute uebersetzt werden muss.

Embeddables, also Value Objects, die als Teil einer Entity-Tabelle gemappt werden, erhalten in Doctrine ORM 3 eine strengere Typpruefung: Konstruktor-Argumente eines Embeddables muessen exakt mit den gemappten Properties uebereinstimmen, und implizite Typumwandlungen, die in ORM 2.x noch toleriert wurden, fuehren jetzt zu einer expliziten Mapping-Exception zur Kompilierzeit des Containers.


<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
final readonly class Money
{
    public function __construct(
        #[ORM\Column(type: 'integer')]
        public int $amountCents,

        #[ORM\Column(type: 'string', length: 3)]
        public string $currency,
    ) {
    }
}

#[ORM\Entity]
class Invoice
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Embedded(class: Money::class)]
    private Money $total;

    public function __construct(Money $total)
    {
        $this->total = $total;
    }
}

Der Vorteil dieser Strenge zeigt sich vor allem bei Refactorings: Wird ein Property im Embeddable umbenannt oder sein Typ geaendert, meldet Doctrine ORM 3 den Fehler sofort beim Kompilieren der Metadaten, statt erst zur Laufzeit bei einem konkreten Datenbankzugriff. Diese Frueherkennung reduziert die Zahl der Migrationsfehler, die erst in der Produktion auffallen.

6. Enum-Support und Identifier-Generierung

Native PHP-Enums werden in Doctrine ORM 3 direkt als Column-Type unterstuetzt, ohne den zuvor noetigen Umweg ueber einen selbst geschriebenen Type. Ein Enum-Property wird direkt mit enumType im #[ORM\Column]-Attribut deklariert, und Doctrine kuemmert sich automatisch um die Konvertierung zwischen dem Datenbankwert und der PHP-Enum-Instanz.


<?php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';
}

#[ORM\Entity]
class Order
{
    #[ORM\Column(enumType: OrderStatus::class)]
    private OrderStatus $status = OrderStatus::Pending;

    public function markAsShipped(): void
    {
        $this->status = OrderStatus::Shipped;
    }
}

Bei der Identifier-Generierung wurde die Unterstuetzung fuer datenbankspezifische Sequenzstrategien vereinheitlicht, sodass SEQUENCE und IDENTITY ueber verschiedene Datenbanktreiber hinweg konsistenter funktionieren als in Doctrine ORM 2.x, wo sich das Verhalten je nach Treiber teilweise unterschied. Projekte mit zusammengesetzten Identifiers sollten vor der Migration gezielt pruefen, ob eigene IdGenerator-Implementierungen noch mit der neuen internen Struktur des Unit of Work kompatibel sind.

7. Migrationsstrategie: Composer, Tests, Staging

Die sicherste Strategie fuer die Migration von Doctrine ORM 2 auf 3 beginnt nicht mit dem Composer-Update selbst, sondern mit einer vollstaendigen Bestandsaufnahme: composer why-not doctrine/orm ^3.0 zeigt blockierende Abhaengigkeiten, eine projektweite Suche nach *.orm.xml und *.orm.yml zeigt den Umfang der noetigen Mapping-Konvertierung, und eine Suche nach ->merge( sowie Proxies\\__CG__ deckt Stellen auf, die von entfernten APIs abhaengen.

Erst nach dieser Bestandsaufnahme folgt das eigentliche Composer-Update in einem separaten Branch, gefolgt von einem vollstaendigen Testlauf. Funktionale Tests, die tatsaechlich gegen eine Datenbank laufen, sind hier wichtiger als reine Unit-Tests, weil viele der Breaking Changes erst beim tatsaechlichen Persistieren und Laden von Entities sichtbar werden. Ein Staging-Deployment mit produktionsnahen Daten vor dem finalen Rollout deckt zudem Performance-Regressionen auf, die bei Lazy Ghost Objects in Einzelfaellen durch veraendertes Initialisierungsverhalten entstehen koennen.

8. Haeufige Stolperfallen bei Custom-Types und Listenern

Eigene Doctrine-Types, die ueber Type::addType() registriert werden, muessen in Doctrine ORM 3 ihre Konvertierungsmethoden mit den aktualisierten Signaturen implementieren, da die zugrunde liegende AbstractPlatform-Klasse in doctrine/dbal ebenfalls Breaking Changes erfahren hat. Ein haeufiger Fehler: ein Custom Type, der bisher implizit von einer bestimmten Plattform-Methode abhing, die in der neuen DBAL-Version umbenannt wurde, faellt erst zur Laufzeit mit einer TypeError auf, nicht bereits beim Kompilieren.

Entity-Listener und Subscriber, die auf Lifecycle-Events wie preFlush oder postLoad reagieren, sollten nach der Migration explizit gegen Lazy Ghost Objects getestet werden, weil ein Zugriff auf eine noch nicht initialisierte Property innerhalb eines postLoad-Listeners jetzt zuverlaessiger die Initialisierung ausloest als bei den alten Proxy-Klassen, was in seltenen Faellen zu zusaetzlichen, vorher nicht vorhandenen Datenbankabfragen fuehren kann.

9. Doctrine ORM 2 vs. 3 im direkten Vergleich

Die folgende Tabelle fasst die wichtigsten Unterschiede zwischen Doctrine ORM 2 und 3 fuer die Migrationsplanung zusammen.

Bereich Doctrine ORM 2.x Doctrine ORM 3.x Migrationsaufwand
Lazy Loading Generierte Proxy-Klassen Lazy Ghost Objects Niedrig, meist transparent
Mapping-Format XML, YAML, Attribute Nur PHP-Attribute Hoch bei XML/YAML
EntityManager::merge() Vorhanden Entfernt Mittel, manueller Ersatz noetig
Native Enum-Unterstuetzung Nur ueber Custom Type Direkt im Column-Attribut Niedrig, optionale Vereinfachung

Der groesste Einzelposten in fast jeder Migration ist die Umstellung von XML- oder YAML-Mapping auf Attribute, waehrend die Lazy-Ghost-Umstellung fuer die meisten Projekte weitgehend transparent verlaeuft, solange keine expliziten Pruefungen gegen generierte Proxy-Klassennamen im Code existieren.

Mironsoft

Doctrine- und Symfony-Migrationen ohne Produktionsausfall

Bereit fuer die Migration auf Doctrine ORM 3?

Wir analysieren euer Mapping, konvertieren XML- und YAML-Definitionen auf Attribute und begleiten den kompletten Rollout von Doctrine ORM 3 inklusive Staging-Tests und Performance-Pruefung.

Mapping-Konvertierung

Von XML und YAML vollstaendig auf PHP-Attribute umgestellt

Breaking-Change-Audit

Suche nach merge(), Proxy-Referenzen und veralteten Custom-Types

Staging-Validierung

Funktionale Tests mit produktionsnahen Daten vor dem Go-Live

10. Zusammenfassung

Die Migration von Doctrine ORM 2 auf 3 ist keine reine Versionsanhebung, sondern beruehrt zentrale Teile jeder Symfony-Anwendung: Lazy Ghost Objects ersetzen generierte Proxy-Klassen, PHP-Attribute werden zum einzigen Mapping-Format, und Methoden wie EntityManager::merge() verschwinden zugunsten expliziterer Alternativen. Native Enum-Unterstuetzung und strenger typisierte Embeddables sind die sichtbarsten Verbesserungen, die den Umstieg rechtfertigen.

Eine erfolgreiche Migration beginnt mit einer vollstaendigen Bestandsaufnahme, gefolgt von der aufwendigsten Einzelaufgabe, der Mapping-Konvertierung, und endet mit einem Staging-Rollout, der funktionale Tests gegen eine echte Datenbank einschliesst. Teams, die diese Reihenfolge einhalten und gezielt nach entfernten APIs wie merge() und generierten Proxy-Klassennamen suchen, bringen Doctrine ORM 3 planbar und ohne Produktionsausfall in bestehende Symfony-Projekte.

Doctrine ORM 2 auf 3 migrieren — Das Wichtigste auf einen Blick

Lazy Ghost Objects

Ersetzen generierte Proxy-Klassen, echte instanceof-Pruefungen, meist transparent fuer Anwendungscode.

Nur noch Attribute

XML- und YAML-Mapping entfernt, vollstaendige Konvertierung ist der aufwendigste Migrationsschritt.

merge() entfernt

Explizites Neuladen ueber find() plus manuelles Feld-Update ersetzt die alte merge()-Methode.

Native Enums

enumType im Column-Attribut ersetzt selbst geschriebene Custom-Types fuer PHP-Enums.

11. FAQ: Doctrine ORM 2 auf 3 migrieren

1Reicht ein einfaches Composer-Update?
Meist nicht. Vorher blockierende Abhaengigkeiten pruefen und XML/YAML-Mapping auf Attribute umstellen.
2Was sind Lazy Ghost Objects?
PHP-native Lazy-Loading-Objekte, ersetzen generierte Proxy-Klassen vollstaendig.
3Warum wurde merge() entfernt?
War eine haeufige Bug-Quelle bei detached Entities, explizite Alternative macht Datenfluss klarer.
4Muss XML-Mapping manuell umgeschrieben werden?
Manuell oder teilautomatisiert mit Rector-Regeln, eine vollstaendig automatische Loesung existiert nicht.
5Funktionieren eigene Doctrine-Types weiter?
Nicht garantiert, gegen neue AbstractPlatform-Signaturen aus doctrine/dbal testen.
6Aendert sich instanceof-Verhalten?
Ja, positiv. Lazy Ghost Objects sind echte Entity-Instanzen, instanceof funktioniert zuverlaessiger.
7Wie finde ich betroffenen Code?
Projektweite Suche nach ->merge(, clear() mit Argument und Proxies\__CG__.
8Braucht die Migration Downtime?
Nicht zwingend, betrifft Anwendungscode nicht das Schema. Staging-Test vorher minimiert Risiko.
9Bringt Doctrine ORM 3 Performance-Vorteile?
Lazy Ghost Objects reduzieren Speicherbedarf, spuerbar bei vielen Lazy-Loading-Beziehungen.
10Koennen ORM 2 und 3 parallel laufen?
Innerhalb desselben Projekts nicht sinnvoll, Feature Branch mit Staging-Validierung stattdessen nutzen.