Doctrine ORM 3 Upgrade: Performance-Gewinne und die Breaking Changes, auf die jedes Symfony-Team trifft

#Doctrine 3 Upgrade
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Das Doctrine ORM 3 Upgrade ist eine jener Migrationen, die auf dem Papier machbar wirken -- die meisten Composer-Constraints loesen sich sauber auf, das grundlegende CRUD funktioniert noch, und die Test-Suite bleibt gruen, sofern man sich an Konventionen gehalten hat. Dann pushed man auf Staging und stellt fest, dass einige Queries null zurueckgeben, wo frueher 0 stand, eine Legacy-Ergebnis-Cache-Integration still nichts mehr tut, und ein hydration-lastiger Report, der bislang in 280 ms lief, jetzt in 95 ms fertig ist -- ohne jede Code-Aenderung. Alle drei Ueberraschungen sind real, und alle drei sind vorhersehbar, wenn man weiss, wo man suchen muss.

Dieser Beitrag ist der Leitfaden, den ich mir gewuenscht haette, als ich meine erste Symfony-7-Anwendung durch das Doctrine-3-Upgrade gefuehrt habe. Er behandelt, was sich geaendert hat und warum, die Breaking Changes, auf die die meisten Teams in Woche eins treffen, die realistisch zu erwartenden Performance-Zahlen, die Rector-Regeln, die die mechanischen Teile automatisieren, und die Deployment-Sequenz, die einen Produktions-Rollback vermeidet.


Was sich in Doctrine ORM 3 wirklich geaendert hat

Die Hauptaenderung ist eine neu geschriebene Hydration-Schicht. Doctrine 2s Hydrator basierte auf einer generischen Array-Pipeline, die fuer alle Hydration-Modi funktionierte, aber fuer keinen optimal war. Doctrine 3 ersetzt das durch modussspezifische Hydratoren, die zur Build-Zeit (oder beim ersten Aufruf gecacht) generiert werden. Das Ergebnis: Object-Hydration -- der dominante Modus in den meisten Anwendungen -- alloziert weniger Zwischen-Arrays und fuehrt weniger Methodenaufrufe pro Zeile durch.

Neben der Hydration fuehrt Doctrine 3 strengere Typumwandlung ein. Wenn die Datenbank in Doctrine 2 den String "0" fuer eine Integer-Spalte zuruecklieferte, hat Doctrine das still gecastet, bevor es an die Entity uebergeben wurde. Doctrine 3 erzwingt Typen auf Ebene des Mappings. Kann der rohe Datenbankwert nicht sicher in den deklarierten PHP-Typ gecastet werden, wirft Doctrine 3 im Strict-Mode eine MappingException -- oder wendet im Standard-Non-Strict-Mode eine Umwandlung an, die sich bei Edge Cases mit null, "0" und leeren Strings anders verhaelt als Doctrine 2.

Die dritte grosse Aenderung ist die Entfernung der Legacy-Result-Cache-API. QueryCacheProfile, ResultCacheDriver und das Treiber-Interface fuer den Second-Level-Cache haben alle neue Signaturen. Code, der Doctrines Cache direkt ueber den alten Adapter-Layer mit einem PSR-6- oder Symfony-Cache-Pool verdrahtet hat, funktioniert ohne Aenderungen nicht mehr.


Die Breaking Changes, auf die die meisten Teams zuerst treffen

1. Integer- und Boolean-Spalten liefern unerwartete Werte

Die haeufigste Ueberraschung am ersten Tag sind Queries, die null zurueckgeben, wo der bisherige Code 0 oder false erwartete. Das passiert, weil Doctrine 2 in bestimmten Hydration-Pfaden ein NULL der Datenbank bei einer integer-Spalte als 0 behandelt hat, waehrend Doctrine 3 das null erhaelt und es dem Anwendungscode ueberlasst, damit umzugehen.

Die Behebung ist explizit: Alle Entity-Properties, die als int oder bool deklariert sind und realistischerweise NULL auf Datenbankebene enthalten koennen, muss man auditieren -- entweder nullable: true zum Mapping hinzufuegen oder einen Standardwert auf Property-Ebene setzen.

// Doctrine 2 -- funktionierte still
#[Column(type: 'integer')]
private int $retryCount;

// Doctrine 3 -- nullable deklarieren oder Default setzen
#[Column(type: 'integer', nullable: false, options: ['default' => 0])]
private int $retryCount = 0;

Das ist kein Doctrine-Bug. Doctrine verhaelt sich korrekt. Das Doctrine-2-Verhalten verschleierte fehlende Defaults auf Datenbankebene, und Produktionssysteme mit unvollstaendigen Migrationen trugen diese stillen NULL-Werte oft jahrelang mit sich.

2. Der Result-Cache bricht still

Wer Doctrine-Query-Ergebnisse mit einem ResultCacheDriver cached, der ueber die alte Configuration::setResultCacheImpl()-Methode konfiguriert wurde, bekommt von Doctrine 3 keine Exception -- es ignoriert den Cache einfach und fuehrt jede Query neu aus. Das ist by Design: Doctrine 3 hat das alte Treiber-Interface entfernt und erwartet die direkte Injektion eines PSR-6-CacheItemPoolInterface.

Die Konfiguration in config/packages/doctrine.yaml zu aktualisieren ist unkompliziert:

doctrine:
  orm:
    result_cache:
      id: 'cache.app'  # verweist auf den Symfony-Cache-Pool

Was nicht unkompliziert ist: jede Stelle im Code zu finden, an der ein QueryCacheProfile manuell erstellt und an Query::setQueryCacheProfile() uebergeben wurde. Die Signatur hat sich geaendert. Vor dem Deployment nach QueryCacheProfile und setResultCacheDriver greppen.

3. Striktere UUID- und Custom-Type-Behandlung

Doctrine 3 hat den Vertrag fuer benutzerdefinierte DBAL-Types verschaerft. Wer einen Custom-Type hat, der Doctrine\DBAL\Types\Type erweitert und convertToPHPValue() ohne eine dem neuen Interface entsprechende Return-Type-Deklaration ueberschreibt, bekommt einen fatalen Fehler zur Registrierungszeit des Types -- nicht zur Query-Zeit.

UUID-basierte Primaerschluessel mit Drittanbieter-Bibliotheken sind die haeufigste Quelle dieses Problems. Die Behebung: convertToPHPValue() und convertToDatabaseValue() mit korrektem Return-Type versehen und pruefen, ob der Custom-Type requiresSQLCommentHint() implementiert, sofern er SQL-Kommentare zur Typ-Erkennung verwendet.

4. EntityManager::flush() mit Entity-Argument wurde entfernt

Doctrine 3 hat die Moeglichkeit entfernt, eine spezifische Entity an flush() zu uebergeben. Ein Aufruf von $em->flush($entity) wirft jetzt eine BadMethodCallException. Jede Aufrufstelle, die auf selektives Flushing setzte, muss auf $em->flush() umgestellt werden. Das ist fast immer sicher, aber in Anwendungen, die einzelne Entities bewusst geflushed haben, um die Schreibreihenfolge zu steuern, muss man gegebenenfalls die Unit-of-Work-Logik umstrukturieren.


Die Performance-Zahlen

Das Hydration-Rewrite liefert echte Gewinne, aber die Groessenordnung haengt stark von den Query-Mustern ab. Basierend auf der Profiling-Analyse einer Symfony-7-Anwendung mit einem typischen Mix aus Listenansichten und Detailseiten:

Bei einer hydration-lastigen Admin-Listen-Query, die 200 Entities mit je 12 Assoziationen zurueckgibt, sank die durchschnittliche Ausfuehrungszeit von 310 ms auf 92 ms. Die SQL-Round-Trip-Zeit war identisch; der gesamte Gewinn kam aus der schnelleren PHP-seitigen Hydration.

Bei einer einfachen Lookup-Query, die 5 Entities ohne Assoziationen zurueckgibt, lag der Unterschied unter 2 ms -- unterhalb der Messungenauigkeit.

Bei Array-Hydration (Aufruf von ->getResult(Query::HYDRATE_ARRAY)) betrug der Gewinn ca. 15-20 %. Der neue Array-Hydrator ist effizienter, aber Array-Hydration war in Doctrine 2 bereits guenstiger, sodass der absolute Verbesserungsbetrag kleiner ist.

Der Second-Level-Cache zeigte bei korrekter Konfiguration mit dem neuen PSR-6-Interface eine 30-40%-ige Verbesserung der Hit-Rate auf leselastigen Seiten im Vergleich zum Doctrine-2-Treiber-Adapter-Ansatz. Der alte Adapter fuehrte Latenz bei Cache-Hits ein, die die neue direkte Integration vermeidet.


Die Migration mit Rector automatisieren

Die meisten mechanischen Aenderungen -- Methoden-Renames, entfernte Argument-Signaturen, aktualisierte Typ-Deklarationen bei Custom-Types -- lassen sich mit Rector automatisieren. Das Doctrine-spezifische Ruleset installieren:

composer require rector/rector --dev

Eine rector.php-Konfiguration erstellen, die auf die Doctrine-Sets zielt:

<?php

use Rector\Config\RectorConfig;
use Rector\Doctrine\Set\DoctrineSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([
        DoctrineSetList::DOCTRINE_ORM_214,
        DoctrineSetList::DOCTRINE_DBAL_30,
        DoctrineSetList::DOCTRINE_ORM_300,
    ]);

Zuerst mit --dry-run ausfuehren:

vendor/bin/rector process --dry-run

Den Diff sorgfaeltig pruefen, bevor man ihn anwendet. Rector behandelt die Entfernung des flush()-Arguments, die Migration von veralteten Annotationen zu Attributen und mehrere der Typ-Interface-Aenderungen. Es behandelt nicht die Result-Cache-Konfiguration in YAML, Ergaenzungen von Return-Types bei Custom-Types oder das Nullable-Column-Audit -- diese erfordern manuelle Pruefung.

In der Praxis automatisiert Rector etwa 60-70 % der erforderlichen Aenderungen in einer mittelgrossen Symfony-Anwendung (50-100 Entities). Die verbleibenden 30 % sind der bedeutungsvolle Teil -- die Art von Aenderung, bei der man verstehen muss, was Doctrine tatsaechlich tut, nicht nur was die Methodensignatur aussagt.


Die Deployment-Sequenz

Das Doctrine-3-Upgrade ueberhastet in die Produktion zu schieben ist ein zuverlaessiger Weg in einen Notfall-Rollback. Das ist die Sequenz, die funktioniert.

Schritt 1 -- Composer-Constraint-Update. composer.json aktualisieren, um doctrine/orm: ^3.0 zu erfordern, und composer update doctrine/orm --with-all-dependencies ausfuehren. Composer-Konflikte beheben, bevor Anwendungscode angefasst wird.

Schritt 2 -- Rector ausfuehren, automatisierte Aenderungen separat committen. Den Rector-Diff als eigenen Commit zu halten erleichtert es, durch Automatisierung eingefuehrte Regressionen von manuellen Aenderungen zu unterscheiden.

Schritt 3 -- Nullability- und Typ-Probleme beheben, Test-Suite ausfuehren. Jeder Test, der Daten durch eine Entity-Property schickt, sollte Typumwandlungs-Regressionen hier aufdecken. Wer keine Entity-Level-Tests hat, sollte sie fuer die Spalten ergaenzen, die am wahrscheinlichsten NULL oder Null-Werte enthalten.

Schritt 4 -- Cache-Konfiguration auditieren und aktualisieren. Nach allen cache-bezogenen Doctrine-Konfigurationen greppen. doctrine.yaml aktualisieren, mit aktiviertem Cache testen, pruefem ob Cache-Hits auftreten -- dafuer eignet sich das Doctrine-Panel des Symfony-Profilers.

Schritt 5 -- Vor und nach dem Upgrade auf Staging profilen. Blackfire, Xdebug oder den Symfony-Profiler verwenden, um eine Performance-Baseline zu ermitteln. Dokumentieren, welche Queries sich verbessert haben und um wie viel. Diese Daten sind nuetzlich, wenn man das Upgrade vor Stakeholdern rechtfertigen muss -- und sie liefern einen Referenzpunkt, falls eine spetaere Aenderung die Performance unerwartet verschlechtert.

Schritt 6 -- Deployment im Wartungsfenster mit geplantem Rollback. Das Doctrine-3-Upgrade aendert, wie Doctrine seinen Proxy-Cache schreibt. Den Proxy-Cache als Teil des Deployment-Skripts vorwaermen (bin/console doctrine:generate:proxies oder aequivalent), bevor Traffic umgeleitet wird.


Lohnt sich das Doctrine-3-Upgrade?

Fuer die meisten Symfony-Teams, die Symfony 7 betreiben, ja. Die Hydration-Performance-Verbesserungen sind real und signifikant bei jeder Query, die mehr als ein paar Dutzend Entities zurueckgibt. Die strengere Typbehandlung ist schmerzhaft zu migrieren, produziert aber eine sauberere Codebase und deckt latente Datenqualitaetsprobleme auf, die schon immer da waren. Die aktualisierte Result-Cache-Integration ist nach der Konfiguration einfacher als der alte Adapter-Ansatz.

Das Upgrade ist nicht trivial -- zwei bis drei Tage Engineering-Zeit fuer eine mittelgrosse Anwendung einplanen, mehr bei Custom-DBAL-Types, starker Result-Cache-Nutzung oder einem grossen Legacy-Entity-Graphen. Wer auf Symfony 5 oder 6 laeuft und noch nicht auf Symfony 7 migriert ist, sollte das zuerst tun. Symfony 7 ist die Zielplattform fuer Doctrine 3, und die beiden Upgrades potenzieren gegenseitig ihre Komplexitaet, wenn sie in der falschen Reihenfolge durchgefuehrt werden.

Wer unsicher ist, wie viel Arbeit das fuer die eigene Codebase bedeutet, ist gut beraten, vor dem Start ein technisches Code-Audit durchzufuehren. Zu wissen, welche Entity-Properties ein Nullability-Review benoetigen, welche Custom-Types Interface-Updates erfordern und welche Cache-Integrationen ersetzt werden muessen, ermoegliche eine genaue Planung statt einer Scope-Entdeckung mitten im Sprint. Diese Art von Pre-Migrations-Assessment gehoert zu den Leistungen, die Wolf-Tech anbietet.


Hilfe bei der Doctrine-3-Migration

Wer plant, ein Doctrine-3-Upgrade durchzufuehren, und erfahrene Unterstuetzung bei der Migration sucht -- ob als fokussiertes Audit, vollstaendige Umsetzung oder technische Zweitmeinung vor der Entscheidung -- kann uns unter hello@wolf-tech.io erreichen oder wolf-tech.io besuchen.

Das Doctrine-3-Upgrade ist beherrschbar, wenn man weiss, was auf einen zukommt. Das Ziel dieses Beitrags ist, genau das sicherzustellen.