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

Sandor Farkas
Gründer & Lead Developer
Experte für Softwareentwicklung und Legacy-Code-Optimierung
LinkedInDas Doctrine ORM 3 Upgrade ist eine jener Migrationen, die auf dem Papier machbar wirken -- die meisten Composer-Constraints lösen sich sauber auf, das grundlegende CRUD funktioniert noch, und die Test-Suite bleibt grün, sofern man sich an Konventionen gehalten hat. Dann pushed man auf Staging und stellt fest, dass einige Queries null zurückgeben, wo früher 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-Änderung. Alle drei Überraschungen sind real, und alle drei sind vorhersehbar, wenn man weiß, wo man suchen muss.
Dieser Beitrag ist der Leitfaden, den ich mir gewünscht hätte, als ich meine erste Symfony-7-Anwendung durch das Doctrine-3-Upgrade geführt habe. Er behandelt, was sich geändert hat und warum, die Breaking Changes, auf die ein Team meist schon in Woche eins trifft, die realistisch zu erwartenden Performance-Gewinne, die Rector-Regeln, die die mechanischen Teile automatisieren, und die Deployment-Sequenz, die einen Produktions-Rollback vermeidet.
Was sich in Doctrine ORM 3 wirklich geändert hat
Die Hauptänderung ist eine neu geschriebene Hydration-Schicht. Doctrine 2s Hydrator basierte auf einer generischen Array-Pipeline, die für alle Hydration-Modi funktionierte, aber für keinen optimal war. Doctrine 3 ersetzt das durch modusspezifische 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 führt weniger Methodenaufrufe pro Zeile durch.
Neben der Hydration führt Doctrine 3 strengere Typumwandlung ein. Wenn die Datenbank in Doctrine 2 den String "0" für eine Integer-Spalte zurücklieferte, hat Doctrine das still gecastet, bevor es an die Entity übergeben 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 verhält als Doctrine 2.
Die dritte große Änderung ist die Entfernung der Legacy-Result-Cache-API. QueryCacheProfile, ResultCacheDriver und das Treiber-Interface für den Second-Level-Cache haben alle neue Signaturen. Code, der Doctrines Cache direkt über den alten Adapter-Layer mit einem PSR-6- oder Symfony-Cache-Pool verdrahtet hat, funktioniert ohne Änderungen nicht mehr.
Die Breaking Changes, auf die die meisten Teams zuerst treffen
1. Integer- und Boolean-Spalten liefern unerwartete Werte
Die häufigste Überraschung am ersten Tag sind Queries, die null zurückgeben, 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, während Doctrine 3 das null erhält und es dem Anwendungscode überlässt, damit umzugehen.
Die Behebung ist explizit: Alle Entity-Properties, die als int oder bool deklariert sind und realistischerweise NULL auf Datenbankebene enthalten können, muss man auditieren -- entweder nullable: true zum Mapping hinzufügen 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 verhält sich korrekt. Das Doctrine-2-Verhalten verschleierte fehlende Defaults auf Datenbankebene, und Produktionssysteme mit unvollständigen 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 über die alte Configuration::setResultCacheImpl()-Methode konfiguriert wurde, bekommt von Doctrine 3 keine Exception -- es ignoriert den Cache einfach und führt 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() übergeben wurde. Die Signatur hat sich geändert. Vor dem Deployment nach QueryCacheProfile und setResultCacheDriver greppen.
3. Striktere UUID- und Custom-Type-Behandlung
Doctrine 3 hat den Vertrag für benutzerdefinierte DBAL-Types verschärft. Wer einen Custom-Type hat, der Doctrine\DBAL\Types\Type erweitert und convertToPHPValue() ohne eine dem neuen Interface entsprechende Return-Type-Deklaration überschreibt, bekommt einen fatalen Fehler zur Registrierungszeit des Types -- nicht zur Query-Zeit.
UUID-basierte Primärschlüssel mit Drittanbieter-Bibliotheken sind die häufigste Quelle dieses Problems. Die Behebung: convertToPHPValue() und convertToDatabaseValue() mit korrektem Return-Type versehen und prüfen, 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 Möglichkeit entfernt, eine spezifische Entity an flush() zu übergeben. 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 Größenordnung hängt 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 zurückgibt, sank die durchschnittliche Ausführungszeit 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 zurückgibt, 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 günstiger, 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 führte Latenz bei Cache-Hits ein, die die neue direkte Integration vermeidet.
Die Migration mit Rector automatisieren
Die meisten mechanischen Änderungen -- 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 ausführen:
vendor/bin/rector process --dry-run
Den Diff sorgfältig prüfen, bevor man ihn anwendet. Rector behandelt die Entfernung des flush()-Arguments, die Migration von veralteten Annotationen zu Attributen und mehrere der Typ-Interface-Änderungen. Es behandelt nicht die Result-Cache-Konfiguration in YAML, Ergänzungen von Return-Types bei Custom-Types oder das Nullable-Column-Audit -- diese erfordern manuelle Prüfung.
In der Praxis automatisiert Rector etwa 60-70 % der erforderlichen Änderungen in einer mittelgroßen Symfony-Anwendung (50-100 Entities). Die verbleibenden 30 % sind der bedeutungsvolle Teil -- die Art von Änderung, bei der man verstehen muss, was Doctrine tatsächlich tut, nicht nur was die Methodensignatur aussagt.
Die Deployment-Sequenz
Das Doctrine-3-Upgrade überhastet in die Produktion zu schieben ist ein zuverlässiger 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 ausführen. Composer-Konflikte beheben, bevor Anwendungscode angefasst wird.
Schritt 2 -- Rector ausführen, automatisierte Änderungen separat committen. Den Rector-Diff als eigenen Commit zu halten erleichtert es, durch Automatisierung eingeführte Regressionen von manuellen Änderungen zu unterscheiden.
Schritt 3 -- Nullability- und Typ-Probleme beheben, Test-Suite ausführen. Jeder Test, der Daten durch eine Entity-Property schickt, sollte Typumwandlungs-Regressionen hier aufdecken. Wer keine Entity-Level-Tests hat, sollte sie für die Spalten ergänzen, 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, prüfen, ob Cache-Hits auftreten -- dafür 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 nützlich, wenn man das Upgrade vor Stakeholdern rechtfertigen muss -- und sie liefern einen Referenzpunkt, falls eine spätere Änderung die Performance unerwartet verschlechtert.
Schritt 6 -- Deployment im Wartungsfenster mit geplantem Rollback. Das Doctrine-3-Upgrade ändert, wie Doctrine seinen Proxy-Cache schreibt. Den Proxy-Cache als Teil des Deployment-Skripts vorwärmen (bin/console doctrine:generate:proxies oder äquivalent), bevor Traffic umgeleitet wird.
Lohnt sich das Doctrine-3-Upgrade?
Für 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 zurückgibt. Die strengere Typbehandlung ist schmerzhaft zu migrieren, produziert aber eine sauberere Codebase und deckt latente Datenqualitätsprobleme 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 für eine mittelgroße Anwendung einplanen, mehr bei Custom-DBAL-Types, starker Result-Cache-Nutzung oder einem großen Legacy-Entity-Graphen. Wer auf Symfony 5 oder 6 läuft und noch nicht auf Symfony 7 migriert ist, sollte das zuerst tun. Symfony 7 ist die Zielplattform für Doctrine 3, und die beiden Upgrades potenzieren gegenseitig ihre Komplexität, wenn sie in der falschen Reihenfolge durchgeführt werden.
Wer unsicher ist, wie viel Arbeit das für die eigene Codebase bedeutet, ist gut beraten, vor dem Start ein technisches Code-Audit durchzuführen. Zu wissen, welche Entity-Properties ein Nullability-Review benötigen, welche Custom-Types Interface-Updates erfordern und welche Cache-Integrationen ersetzt werden müssen, ermöglicht eine genaue Planung statt einer Scope-Entdeckung mitten im Sprint. Diese Art von Pre-Migrations-Assessment gehört zu den Leistungen, die Wolf-Tech anbietet.
Hilfe bei der Doctrine-3-Migration
Wer plant, ein Doctrine-3-Upgrade durchzuführen, und erfahrene Unterstützung bei der Migration sucht -- ob als fokussiertes Audit, vollständige 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 weiß, was auf einen zukommt. Das Ziel dieses Beitrags ist, genau das sicherzustellen.
