Upgrade von Doctrine ORM 2 auf 3 (und DBAL 3 auf 4) in einer Symfony-Anwendung
Annotations zu Attributes, Lazy Ghosts statt Proxies, eigene DBAL-Typen, entfernte APIs, die DoctrineBundle-Flags, die zuerst gesetzt werden, und wie man prüft, dass sich das Schema nicht geändert hat.
Veröffentlicht 2026-09-12. Geschrieben für Teams, die das Upgrade selbst machen; wer es lieber machen lässt, findet hier Upgrades zum Festpreis. Auch verfügbar auf English.
Was Sie tatsächlich upgraden
"Doctrine ORM 3" sind drei Upgrades, die meist zusammen passieren: ORM 2.x → 3.x, DBAL 3.x → 4.x und Annotations → Attributes, weil ORM 3 den Annotation-Driver gestrichen hat. DoctrineBundle 2.11 und neuer unterstützt beide ORM-Majors, also lässt sich jeder Schritt auf Ihrer aktuellen Symfony-Version (6.4 oder sogar 5.4) erledigen und getrennt ausliefern. ORM 2 erreicht im Februar 2027 das End of Life; DoctrineBundle 3 und API Platform 4 erwarten ORM 3.
Schritt 1: neuestes 2.x, und die Deprecations lesen
composer require doctrine/orm:^2.20 doctrine/dbal:^3.9 doctrine/doctrine-bundle:^2.13 --with-all-dependencies
ORM 2.19+ und DBAL 3.6+ loggen für jede API, die 3.0 und 4.0 entfernt haben, eine Deprecation über doctrine/deprecations, die die Symfony-PHPUnit-Bridge einsammelt. Suite mit SYMFONY_DEPRECATIONS_HELPER=max[self]=0 ausführen, und Sie haben die Liste. Typische Einträge:
- "Using annotations for mapping is deprecated" → Schritt 3.
- "Calling flush() with an entity argument is deprecated" →
flush()ohne Argumente. - "Doctrine\ORM\Proxy\Proxy is deprecated" und "lazy ghost objects" → Schritt 4.
- "Statement::execute() is deprecated, use executeQuery()/executeStatement()" → Schritt 5.
- "Type::getName() is deprecated", "requiresSQLCommentHint() is deprecated" → eigene Typen, Schritt 5.
- "ClassMetadataInfo is deprecated, use ClassMetadata" → umbenennen.
- "Doctrine\Common\Persistence" →
Doctrine\Persistence(in 2.x-Codebasen meist schon erledigt).
Schritt 2: DoctrineBundle-Flags, die 2.x wie 3.x verhalten lassen
# config/packages/doctrine.yaml
doctrine:
orm:
enable_lazy_ghost_objects: true # ORM-3-Proxies, auf 2.x mit DoctrineBundle 2.11+ verfügbar
report_fields_where_declared: true # 3.x-Verhalten beim Vererbungs-Mapping
validate_xml_mapping: true
controller_resolver:
auto_mapping: false # 3.x: Entities werden nur noch mit #[MapEntity] aufgelöst
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity # Sequenzen → Identity-Spalten auf 3.x
Jedes davon kann etwas Bestimmtes brechen. auto_mapping: false ist das, was überrascht: Jedes Controller-Argument, das stillschweigend aus einem Routen-Parameter aufgelöst wurde, braucht jetzt #[MapEntity]. Symfony 6.2+ hat das Attribute; Rector fügt es hinzu.
#[Route('/orders/{id}')]
public function show(#[MapEntity] Order $order): Response {}
Schritt 3: Annotations zu Attributes
// rector.php
use Rector\Doctrine\Set\DoctrineSetList;
return RectorConfig::configure()
->withPaths([__DIR__.'/src'])
->withSets([
DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
DoctrineSetList::DOCTRINE_ORM_29,
DoctrineSetList::DOCTRINE_DBAL_40,
DoctrineSetList::DOCTRINE_ORM_300,
]);
// Vorher
/**
* @ORM\Entity(repositoryClass=OrderRepository::class)
* @ORM\Table(name="orders", indexes={@ORM\Index(name="idx_status", columns={"status"})})
*/
class Order
{
/** @ORM\Id @ORM\GeneratedValue @ORM\Column(type="integer") */
private $id;
/** @ORM\ManyToOne(targetEntity=Customer::class, inversedBy="orders") @ORM\JoinColumn(nullable=false) */
private $customer;
}
// Nachher
#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
#[ORM\Index(name: 'idx_status', columns: ['status'])]
class Order
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'orders')]
#[ORM\JoinColumn(nullable: false)]
private ?Customer $customer = null;
}
Dann den Driver umstellen und das Annotations-Paket entfernen:
doctrine:
orm:
mappings:
App:
type: attribute # war 'annotation'
dir: '%kernel.project_dir%/src/Entity'
prefix: 'App\Entity'
composer remove doctrine/annotations # nur, wenn nichts anderes es braucht (Symfony-Routing, API Platform 2, JMS)
bin/console doctrine:mapping:info
bin/console doctrine:schema:validate
schema:validate muss vor und nach diesem PR "The database schema is in sync" melden. Ist der Diff nicht leer, hat eine Attribute-Übersetzung eine Option verloren (typisch: columnDefinition, options={"default": ...}, eigene @ORM\Index-Flags). Das Mapping korrigieren, nicht die Datenbank.
Schritt 4: Lazy Ghosts statt Proxies
ORM 3 erzeugt lazy-loadende Entities mit Symfonys VarExporter (enable_lazy_ghost_objects, oben aktiviert). Die sichtbaren Unterschiede:
- Proxies implementieren nicht mehr
Doctrine\ORM\Proxy\ProxyoderDoctrine\Persistence\Proxymit__load(). Code, der mitinstanceof Proxydas Laden erzwang oder die "echte" Klasse holte, sollte Ansätze ohneClassUtils::getClass()nutzen:$em->getClassMetadata($entity::class)->getName()oder$em->initializeObject($entity). - Nicht initialisierte Ghosts haben keine Property-Werte, nicht einmal den Identifier, bis sie berührt werden;
$order->getId()initialisiert das Objekt (das ORM optimiert den Identifier-Zugriff, aber Code, der Properties per Reflection liest, profitiert davon nicht). - Das Serialisieren eines nicht initialisierten Ghosts (Symfony Serializer,
json_encodenach__serialize) löst ein Laden aus; das muss innerhalb eines Requests mit offener Verbindung passieren.
Schritt 5: DBAL 4
DBAL 4 ist die Stelle, an der eigene Typen und rohes SQL brechen. Checkliste:
// Eigener Typ, DBAL-4-Signaturen
final class MoneyType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return $platform->getIntegerTypeDeclarationSQL($column);
}
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Money
{
return null === $value ? null : Money::fromCents((int) $value);
}
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?int
{
return $value?->cents();
}
// getName() und requiresSQLCommentHint() sind weg: den Typ per Name in doctrine.yaml registrieren
}
doctrine:
dbal:
types:
money: App\Doctrine\MoneyType
Types::ARRAYundTypes::OBJECT(PHP-serialize()-Spalten) wurden in DBAL 4 entfernt. Diese Spalten mit einer Datenmigration aufjsonumstellen; eine Abkürzung gibt es nicht.- Rohe Queries:
$conn->executeQuery($sql, $params)->fetchAllAssociative();fetchAll(),fetch(),execute()auf Statements sind weg. - Platform-Klassennamen haben sich geändert (
MySQL80Platform,PostgreSQLPlatform);server_versionin der DBAL-Konfiguration setzen, damit Doctrine die richtige Platform ohne Verbindung beim Kompilieren wählt. Connection::getSchemaManager()→createSchemaManager();Schema::getTables()liefert Objekte mit anderen Keys; Migrationen, die Schema-Objekte direkt manipulieren, brauchen einen Blick.
Schritt 6: der Sprung
composer require doctrine/orm:^3.5 doctrine/dbal:^4 doctrine/doctrine-bundle:^2.15 --with-all-dependencies
bin/console doctrine:schema:validate
bin/console doctrine:migrations:diff # darf nichts erzeugen, oder nur die gewählte PostgreSQL-Identity-Änderung
Entfernte APIs, auf die Sie an dieser Stelle noch stoßen können, keine davon mit Deprecation in 2.x, weil sie längst ersetzt waren: EntityManager::create() (stattdessen new EntityManager($connection, $config), oder einfach der Container), Setup::createAnnotationMetadataConfiguration() (ORMSetup::createAttributeMetadataConfiguration()), EntityManager::merge(), die detach()-Semantik, Query::iterate() (toIterable()) und der Default von $fetchJoinCollection im Konstruktor von Doctrine\ORM\Tools\Pagination\Paginator.
Extensions: gedmo/doctrine-extensions 3.14+, stof/doctrine-extensions-bundle 1.10+, knplabs/doctrine-behaviors 2.6+, vich/uploader-bundle 2.x und doctrine/doctrine-fixtures-bundle 3.6+ unterstützen ORM 3. Die Kompatibilitätsseiten zeigen, welches Release jedes Bundle für Symfony 7.4 braucht; für die ORM-Seite den ORM-Constraint auf Packagist prüfen.
Reihenfolge der Pull Requests
- Neuestes 2.x/3.x, Deprecations sichtbar
- DoctrineBundle-Flags an (jedes Flag kann ein eigener PR sein, wenn die Suite dünn ist)
- Annotations → Attributes,
schema:validatesauber - Eigene Typen und rohes SQL auf DBAL-4-Signaturen
ARRAY-/OBJECT-Spalten → JSON mit Datenmigration- Sprung auf ORM 3 + DBAL 4,
migrations:diffleer
Als Add-on zu einem Symfony-Upgrade sind das €1.500 bis €3.500; die Spanne hängt von der Zahl eigener Typen, serialisierter Array-Spalten und roher DBAL-Aufrufe ab.
Steht Doctrine ORM 2 in Ihrer composer.lock?
Der Scan erkennt ORM unter 3 und doctrine/annotations als Cliffs, zeigt das benötigte DoctrineBundle-Release und setzt den ORM-Schritt an die richtige Stelle in Ihrem Upgrade-Pfad.
Weitere Leitfäden
- Upgrade von Symfony 5.4 auf 6.4: der vollständige Leitfaden
- Upgrade von Symfony 6.4 auf 7.4: Deprecations, Return Types und der Sprung
- API Platform 2.6 auf 3: Resources, Data Provider und Persister migrieren
- API Platform 3 auf 4: der State-basierte Ablauf, RFC-7807-Fehler und Hydra-Änderungen
- Verwaiste Symfony-Bundles ersetzen: SensioFrameworkExtra, Guard, Swiftmailer, FOSRest, FOSUser