KwikTech Symfony · API Platform · Doctrine upgrades
Upgrade-Leitfaden · 12 Min. Lesezeit

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\Proxy oder Doctrine\Persistence\Proxy mit __load(). Code, der mit instanceof Proxy das Laden erzwang oder die "echte" Klasse holte, sollte Ansätze ohne ClassUtils::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_encode nach __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::ARRAY und Types::OBJECT (PHP-serialize()-Spalten) wurden in DBAL 4 entfernt. Diese Spalten mit einer Datenmigration auf json umstellen; 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_version in 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

  1. Neuestes 2.x/3.x, Deprecations sichtbar
  2. DoctrineBundle-Flags an (jedes Flag kann ein eigener PR sein, wenn die Suite dünn ist)
  3. Annotations → Attributes, schema:validate sauber
  4. Eigene Typen und rohes SQL auf DBAL-4-Signaturen
  5. ARRAY-/OBJECT-Spalten → JSON mit Datenmigration
  6. Sprung auf ORM 3 + DBAL 4, migrations:diff leer

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.

Mein Projekt scannen Assessment anfragen (€750)

Weitere Leitfäden