Blog
Doctrine UnitOfWork — Wenn sich Entities anders verhalten als erwartet
Eine Entity ändert sich im PHP-Objekt, aber die Datenbank bleibt unverändert. Häufig liegt die Ursache nicht im Setter, sondern im Zustand dieser Instanz im aktuellen EntityManager.
Ein Fehler, der unmöglich wirkt
Die Bestellung meldet den Status bezahlt, flush() endet ohne Fehler, doch in der Datenbank hat sich nichts geändert. Eine mögliche Erklärung: Das PHP-Objekt lebt länger als der Persistenzkontext, der seine Änderungen verfolgt hat.
Das folgende Beispiel setzt eine vorhandene, unbezahlte Bestellung, ein gewöhnliches gemapptes Statusfeld und ein Repository voraus, das denselben offenen EntityManager wie der übrige Code verwendet. markAsPaid() ändert dieses Feld, führt aber weder eigenes SQL aus noch registriert es das Objekt erneut.
$order = $orderRepository->find($id);
if ($order === null) {
throw new OrderNotFound($id);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();OrderNotFound ist eine projektspezifische Exception, keine Doctrine-Klasse. Die ausgelassenen Entities und das Repository dienen der Veranschaulichung; das Beispiel beschreibt keinen belegten Vorfall bei einem GiSoft-Kunden.
clear() löst die geladene Bestellung aus der Verwaltung. Das PHP-Objekt wird weder zerstört noch werden seine Eigenschaften zurückgesetzt. Deshalb kann $order->getStatus() anschließend paid liefern. Der spätere flush() plant aber kein Update für diese abgelöste Instanz. Andere nach clear() registrierte Änderungen könnten durchaus geschrieben werden.
Objektzustand und Beteiligung am Speichervorgang entwickeln sich hier auseinander:
Operation PHP-Status Entity-Zustand Status schreiben
find() unpaid MANAGED —
clear() unpaid DETACHED —
markAsPaid() paid DETACHED —
flush() paid DETACHED kein UPDATEEin sinnvoller erster Prüfschritt ist:
dump($order->getStatus());
dump($entityManager->contains($order));Die Werte sind hier paid und false. Letzteres bedeutet, dass dieser EntityManager die Instanz im Sinne von contains() gerade nicht verwaltet. Es beweist für sich genommen nicht den Zustand DETACHED: Auch ein neues, noch nicht registriertes Objekt oder eine zur Löschung vorgemerkte Entity kann false liefern. Eine von einem anderen EntityManager verwaltete Instanz ist hier ebenfalls nicht automatisch verwaltet. Im Eingangsbeispiel kennen wir den Grund, weil wir Laden und anschließendes Leeren nachvollziehen können.
Was die UnitOfWork verfolgt
Die UnitOfWork führt für den EntityManager Buch über Instanzen: Identitäten, erfasste Werte und ausstehende Operationen an Entities oder Collections. Bei der standardmäßigen Änderungsverfolgung DEFERRED_IMPLICIT vergleicht sie beim Flush gemappte Werte und ermittelt daraus die erforderlichen Schreiboperationen. Sie durchsucht nicht sämtliche PHP-Variablen oder Service-Eigenschaften.
Die Änderungsverfolgung ist konfigurierbar. Bei DEFERRED_EXPLICIT müssen verwaltete Entities durch persist() oder eine passende Kaskade zur Prüfung ausgewählt werden. Auch schreibgeschützte Mappings, die besitzende Seite einer Beziehung und die Art des geänderten Werts spielen eine Rolle. Der verwaltete Zustand ist für gewöhnliche nachverfolgte Updates nötig, bedeutet aber nicht, dass jeder Methodenaufruf ein UPDATE auslöst.
Die vier Lebenszykluszustände beziehen sich auf eine Instanz im jeweiligen Persistenzkontext:
Zustand Bedeutung in diesem Kontext
NEW Neue, noch nicht verwaltete Entity;
ihr INSERT ist noch nicht vorgemerkt.
MANAGED Diesem EntityManager zugeordnet, nicht REMOVED.
Tracking richtet sich nach Mapping und Strategie.
DETACHED Repräsentiert eine persistente Identität,
wird von diesem EntityManager nicht verwaltet.
REMOVED Bestehende Entity, zum Löschen beim flush()
vorgemerkt.Ein durch persist() registriertes neues Objekt kann schon verwaltet sein, bevor seine Zeile eingefügt wurde. Umgekehrt beweist eine ID am Objekt nicht, dass dieser EntityManager es verwaltet. Lebenszykluszustand, PHP-Werte und Datenbankinhalt hängen zusammen, sind aber nicht gleichzusetzen.
clear() und die Identity Map
clear() leert die UnitOfWork einschließlich ausstehender Änderungen. Noch nicht geschriebene Arbeit ist danach nicht mehr zur Persistierung vorgemerkt. PHP-Referenzen bleiben bestehen. Der Aufruf bestätigt keine Datenbanktransaktion und rollt auch keine zurück. Er schließt den EntityManager ebenfalls nicht: Dieser kann anschließend neue verwaltete Instanzen laden.
Die Identity Map erklärt, weshalb eine alte Referenz relevant bleibt. Angenommen, Benutzer 42 existiert und zwischen diesen Aufrufen findet weder Clear noch Detach noch ein Managerwechsel statt:
$userA = $entityManager->find(User::class, 42);
$userB = $entityManager->find(User::class, 42);Dann gilt $userA === $userB. Doctrine ordnet innerhalb dieses Kontexts einer persistenten Identität eine verwaltete Instanz zu; der zweite find() muss die Datenbank nicht erneut lesen. Nach clear() kann dieselbe Identität eine andere Instanz ergeben. Das frühere Objekt wird nicht allein durch eine übereinstimmende ID wieder verwaltet.
Bei einem Import kann eine ähnliche Unklarheit entstehen:
$userFromDatabase = $userRepository->find(42);
$userFromPayload = User::fromImportedPayload($payload);User::fromImportedPayload() ist eine hypothetische Factory des Projekts. Für das Beispiel nehmen wir an, dass sie aus validierten Eingabedaten für Benutzer 42 ein separates Objekt erzeugt. Der Methodenname belegt weder die zugewiesene ID noch einen Doctrine-Zustand; beides hängt von Implementierung und Mapping ab. Selbst dieselbe ID macht das Objekt nicht automatisch zur Repository-Instanz. Zulässige Importänderungen sollten an der verwalteten Entity vorgenommen werden, statt ein beliebig rekonstruiertes Objekt als Datenbankupdate zu behandeln.
persist(), flush() und die eigentliche Korrektur
Für eine tatsächlich neue, gemappte Entity haben diese beiden Aufrufe unterschiedliche Aufgaben:
$entityManager->persist($entity);
$entityManager->flush();persist() nimmt die neue Instanz in die Verwaltung auf und merkt das Einfügen vor. flush() führt die ausstehenden Schreiboperationen aus. Daraus folgt nicht, dass persist() niemals Datenbankarbeit verursacht: ID-Erzeugung oder Callbacks können bereits vor dem INSERT aktiv werden.
Eine bestehende verwaltete Entity benötigt bei der Standardstrategie nach einer Feldänderung normalerweise kein erneutes persist(). Die zuvor beschriebene explizite Änderungsverfolgung ist ein anderer Fall. Auf eine abgelöste Entity angewandt ist persist() kein allgemeines Wiederanbinden: Doctrine kann ein unbekanntes Objekt als neu behandeln, sodass statt des gewünschten Updates Einfügeversuche oder Fehler entstehen.
Ältere ORM-2-Beispiele verwenden mitunter merge(), um abgelösten Zustand in eine verwaltete Instanz zu kopieren. Dabei wurde nicht einfach das ursprüngliche Objekt selbst zur verwalteten Instanz. Die Merge-Unterstützung wurde in ORM 3 entfernt; in ORM 3.6 ist die Methode nicht vorhanden. Als heutige Reparaturanleitung taugt sie deshalb nicht.
Bei der abgelösten Bestellung sollte ein Repository des aktuellen, offenen EntityManagers die Bestellung laden. Die beabsichtigte Operation wird dann an der zurückgegebenen Instanz ausgeführt:
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$order->markAsPaid();
$entityManager->flush();Das ist keine Aufforderung, alle Eigenschaften aus dem veralteten Objekt zu kopieren. Fachliche Voraussetzungen, Berechtigungen und gegebenenfalls die erwartete Version müssen erneut geprüft werden. Bei konkurrierenden Änderungen sind die vorgesehenen Schutzmechanismen nötig, etwa optimistisches Locking. find() kann eine bereits verwaltete Instanz oder einen konfigurierten Cache verwenden und garantiert nicht grundsätzlich die neuesten bestätigten Daten.
Der Ausschnitt setzt voraus, dass der aufrufende Code diesen Flush verantwortet und dazwischen weder Clear noch Managerwechsel stattfindet. Mit einem neuen EntityManager zu laden und den alten zu flushen würde den ursprünglichen Fehler nicht beheben.
Messenger, gespeicherte Entities und Serialisierung
Ein klassischer PHP-FPM-Request gibt seine requestlokalen Objekte nach dem Ende der Ausführung frei. Ein langlebiger Messenger-Prozess kann Services dagegen für viele Nachrichten wiederverwenden. Eine Service-Eigenschaft kann deshalb eine Entity behalten, obwohl der ladende Persistenzkontext bereits geleert oder ersetzt wurde:
final class CustomerContext
{
private ?Customer $customer = null;
public function remember(Customer $customer): void
{
$this->customer = $customer;
}
public function customer(): ?Customer
{
return $this->customer;
}
}Das ist bewusst ein Beispiel für riskant gespeicherten Zustand; eine Reset-Behandlung fehlt. Überlebt CustomerContext den Wechsel des Doctrine-Kontexts, kann customer() eine alte Instanz liefern. Wird der Service selbst zurückgesetzt oder neu erstellt, entfällt möglicherweise dieser konkrete Weg, die Referenz zu behalten.
Symfony stellt Mechanismen zum Zurücksetzen von Services bereit. Ihr Verhalten hängt jedoch von Symfony- und DoctrineBundle-Version, Reset-Konfiguration, Worker-Optionen und den eigenen Services ab. Nicht jeder Worker leert oder ersetzt jeden EntityManager nach jeder Nachricht auf dieselbe Weise. Muss ein Service Zustand halten, sollte seine Bereinigung in den konfigurierten Reset-Ablauf eingebunden und mit mehreren aufeinanderfolgenden Nachrichten getestet werden.
Nachrichten sollten in der Regel IDs oder bewusst gestaltete DTOs statt lebender ORM-Entities transportieren. Die folgenden Aufrufe zeigen alternative Nachrichtenentwürfe, nicht zwei Konstruktorsignaturen derselben Implementierung.
Eine Nachricht mit Entity:
new GenerateInvoiceMessage($invoice);Eine Nachricht mit ID:
new GenerateInvoiceMessage($invoiceId);Im zweiten Entwurf lädt der Handler die Rechnung über sein aktuelles Repository:
$invoice = $invoiceRepository->find($message->invoiceId);
if ($invoice === null) {
throw new InvoiceNotFound($message->invoiceId);
}GenerateInvoiceMessage, dessen Eigenschaft invoiceId und InvoiceNotFound gehören zum Projektbeispiel. Der Handler muss weiterhin Löschung, Autorisierung und Änderungen seit dem Versand behandeln. Eine ID allein macht weder eine Wiederholung noch einen externen Seiteneffekt sicher.
Wird eine Entity in eine Queue, Session oder einen Cache serialisiert, wird ihre Zugehörigkeit zum EntityManager nicht mittransportiert. Ein rekonstruiertes Objekt ist nicht automatisch bei der empfangenden UnitOfWork registriert. Das bedeutet weder, dass das synchrone Erstellen einer Nachricht ihr Argument ablöst, noch dass Serialisierung die beim Sender verbliebene Originalreferenz aus der Verwaltung entfernt.
Auch Lazy Loading hängt von ORM-Version, Mapping, Proxy-Technik und Serializer ab. Eine ungeladene Beziehung kann nach dem Transport nicht mehr funktionieren oder nach einem Clear noch über einen erhaltenen Lademechanismus auf Daten zugreifen. Bereits geladene Werte können lesbar bleiben. Weder erfolgreicher Zugriff noch eine Exception belegt den verwalteten Zustand der besitzenden Entity. Benötigte Daten gehören in den aktuellen Operationskontext; Beziehungszugriffe reparieren keine abgelöste Instanz. Alle Beziehungen eager zu laden bindet das Objekt nicht wieder an und kann unnötige Daten laden.
Wer verantwortet die Transaktion?
Man stelle sich einen Controller mit vier Service-Aufrufen vor: A führt einen Flush aus, B leert den EntityManager, C ändert die zuvor geladene Bestellung und D flusht erneut. Entscheidend ist nicht die Zahl der Services, sondern eine vereinbarte Persistenz- und Transaktionsgrenze für ihre gemeinsame Arbeit.
Vier Ebenen sind auseinanderzuhalten: Lebensdauer des EntityManagers, Änderungsverfolgung in der UnitOfWork, SQL-Ausführung und abschließender Commit oder Rollback der Datenbanktransaktion. Ohne explizite umschließende Transaktion verwendet ein Flush mit Schreiboperationen normalerweise Doctrines implizite Transaktionsbehandlung. Innerhalb einer expliziten Transaktion beweist ein erfolgreicher flush() noch keinen abschließenden Commit.
Die Grenze kann bei einem Anwendungsservice, Command-Handler oder einer konfigurierten Transaktions-Middleware liegen. Symfonys doctrine_transaction kann nach den Handlern Flush und Commit ausführen. Ein direkter Handler-Aufruf im Test umgeht diese Middleware. Vor einem weiteren Flush in einem untergeordneten Service muss klar sein, wer das Schreiben verantwortet.
Ein Rollback setzt die PHP-Eigenschaften nicht auf ihre früheren Werte zurück. Bestimmte Fehler beim Flush schließen den EntityManager; clear() öffnet ihn nicht wieder. Zur Wiederaufnahme braucht es den vorgesehenen Manager-Reset der Anwendung und das Verwerfen alter Entity-Referenzen, keinen blinden Wiederholungsversuch mit demselben geschlossenen Manager. Mehrere Transaktionsgrenzen sind für Stapelverarbeitung sinnvoll, wenn Teilfortschritt und Wiederaufnahme bewusst geplant sind.
Stapelverarbeitung ohne alte Objekte
Ein Import kann regelmäßig Flush und Clear ausführen, um die Zahl verwalteter Objekte zu begrenzen. Dies ist ein Ablaufgerüst: Der Kommentar steht für ausgelassene Zeilenvalidierung und das Erzeugen oder Ändern von Entities, einschließlich persist() für neue Objekte.
$processed = 0;
foreach ($rows as $row) {
// Zeile validieren; Entities erzeugen oder ändern.
if (++$processed % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();Der Zähler hängt nicht von den Schlüsseln der Eingabedaten ab. Der abschließende Flush verarbeitet einen unvollständigen Reststapel; das letzte Clear gibt UnitOfWork-Referenzen frei. Weder die Verarbeitung der nächsten Zeile noch beteiligte Services dürfen danach Entities aus der Zeit vor dem Clear wiederverwenden.
Bei bestehenden Bestellungen lassen sich IDs weitergeben und die Objekte im aktiven Kontext laden:
if ($batchSize < 1) {
throw new \InvalidArgumentException('batchSize >= 1');
}
$processed = 0;
foreach ($orderIds as $orderId) {
$order = $orderRepository->find($orderId);
if ($order === null) {
continue;
}
$order->recalculateTotals();
if (++$processed % $batchSize === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();$batchSize ist eine ganze Zahl, $orderIds ein Iterable gültiger IDs. Fehlende Bestellungen werden hier bewusst übersprungen; ein anderer Ablauf müsste sie vielleicht protokollieren oder zurückweisen. Gezählt werden verarbeitete Bestellungen, nicht Eingabepositionen. Fehlende Datensätze können deshalb keine Stapelgrenze überspringen. Der letzte Flush ist unproblematisch, wenn bereits alles geschrieben wurde und nichts Neues aussteht.
Beide Beispiele setzen einen eigenen offenen EntityManager, normale implizite Änderungsverfolgung und keine umschließende Transaktion oder Middleware voraus, die diese Grenzen verändert. Jeder erfolgreich geschriebene Stapel kann daher separat bestätigt werden. Ein späterer Fehler nimmt frühere Commits nicht zurück. Exceptions beenden diese Ablaufgerüste; Fortschrittsmarken, begrenzte Wiederholungen, Idempotenz und Fehlerwiederaufnahme sind ausgelassen, nicht automatisch vorhanden.
Clear begrenzt Doctrines gespeicherten Zustand, nicht zwangsläufig den gesamten Prozessspeicher. Eingabearrays, Service-Eigenschaften, Logger oder die letzte Variable $order können weiter Objekte halten. Große ID-Mengen sollten bei Bedarf schrittweise eingelesen und der Speicherverbrauch gemessen werden. clear() zu entfernen, nur um Ablösung zu verdecken, löst das Speicherproblem nicht.
Kontext, Änderungsverfolgung und Transaktion getrennt prüfen
Wenn der erste contains()-Befund die Ursache nicht klärt, braucht es Belege auf mehreren Ebenen. Die folgenden Prüfungen sind unabhängig; ein bestandener Schritt beweist nicht den nächsten:
- Feststellen, welches Repository die Instanz geladen hat und welcher EntityManager geflusht wird. Clear, Close, Reset und gehaltene Service-Referenzen nachvollziehen.
isOpen()prüft, ob der Manager offen ist, nicht ob er dieses Objekt verwaltet. - Mapping des Feldes, Tracking-Strategie, Schreibschutz und besitzende Seite geänderter Beziehungen prüfen. Lifecycle-Listener können einen Wert ebenfalls ändern oder zurücksetzen.
- Das relevante SQL mit einem zur installierten Version passenden Profiler oder DBAL-Middleware beobachten. Ein nicht eingeplantes Update ist etwas anderes als ein ausgeführter Schreibzugriff mit anschließendem Rollback oder Fehler.
- Ergebnis der umschließenden Transaktion sowie Datenbank, Mandant und geprüfte Leseverbindung bestätigen. Cache, Replikationsverzögerung oder ein anderer Schreibzugriff können einen abweichenden Wert erklären.
Für eine genauere Zustandsunterscheidung bietet ORM 3.6 diese Diagnose-API:
$state = $entityManager->getUnitOfWork()->getEntityState($order);Sie liefert eine Konstante aus Doctrine\ORM\UnitOfWork::STATE_*. Bei einer unbekannten Instanz kann die Unterscheidung zwischen neu und abgelöst einen Datenbankzugriff benötigen. Das Ergebnis ergänzt die Objekthistorie; es ist keine fachliche Entscheidungsregel.
Auch der Zeitpunkt einer Changeset-Prüfung ist wichtig. Vor der regulären Berechnung kann die Änderungsmenge leer sein, nach einem erfolgreichen Flush bereits wieder geleert. Ein leeres Changeset allein beweist deshalb weder Ablösung noch ein fehlgeschlagenes Update. Der interne UnitOfWork-Zustand sollte nicht verändert werden, nur damit eine Diagnose das gewünschte Ergebnis zeigt.
Datenbankwirkung zusätzlich zum Objekt testen
Ein Unit-Test kann zeigen, dass eine unbezahlte Bestellung ihren PHP-Zustand ändert:
$order->markAsPaid();
expect($order->isPaid())->toBeTrue();Persistenz belegt er nicht. Ein Integrationstest benötigt eine bereits gespeicherte unbezahlte Testbestellung und einen erneuten Lesezugriff, der nicht dieselbe Instanz zurückliefert:
$orderService->markAsPaid($orderId);
$entityManager->flush();
$entityManager->clear();
$reloadedOrder = $orderRepository->find($orderId);
expect($reloadedOrder?->isPaid())->toBeTrue();Hier verändert OrderService::markAsPaid($orderId) eine verwaltete Bestellung, führt aber weder Flush noch Clear aus und verantwortet keine Transaktion. Den Flush übernimmt bewusst der Test. Besitzt der reale Service den Commit, sollte dieser Vertrag ohne zusätzlichen Flush getestet werden. Ist Middleware zuständig, muss der Test den Bus nutzen oder die entsprechende Grenze ausdrücklich nachbilden.
Repository, Service und Test müssen den vorgesehenen EntityManager und dieselbe Datenbank verwenden. Clear verhindert die Wiederverwendung aus der Identity Map, deaktiviert aber keinen Ergebnis- oder Second-Level-Cache. Bei kontrollierten Caches kann der erneute Zugriff den innerhalb der Testtransaktion sichtbaren Zustand prüfen. Ein Test in einer am Ende zurückgerollten äußeren Transaktion belegt keinen dauerhaften Produktions-Commit. Dafür braucht es einen geeigneten Test mit bestätigtem Schreibzugriff und einer unabhängigen Leseverbindung zur maßgeblichen Datenbank.
Das wiederholte Beispiel der abgelösten Bestellung ist als Regressionstest nützlicher denn als weiterer unkommentierter Codeblock. Ausgangspunkt ist erneut eine gespeicherte, unbezahlte Testbestellung:
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();
$reloadedOrder = $orderRepository->find($orderId);
expect($order->isPaid())->toBeTrue()
->and($entityManager->contains($order))->toBeFalse()
->and($reloadedOrder?->isPaid())->toBeFalse();Die Assertions unterscheiden den bezahlten Zustand des alten Objekts vom unbezahlten Zustand einer neu gelesenen Instanz. Es handelt sich um Pest-artige Testausschnitte. Testdatenaufbau, Isolation, Mappings und Service-Konfiguration sind ausgelassen; die Beispiele sind nicht eigenständig ausführbar.
Hilfreiche Informationen aus Produktion
Statt vollständiger Entities sollten ausgewählte Metadaten protokolliert werden: Korrelations-ID, angemessen geschützte Entity-ID, Nachrichten- oder Befehlstyp, Wiederholungszahl, beobachtete Clear-/Reset-Ereignisse und Transaktionsergebnis. SQL-Zeiten, Worker-Speicher und Informationen zu fehlgeschlagenen Nachrichten helfen dort, wo die nötige Instrumentierung eingerichtet ist. Die Rückkehr aus einem Handler beweist keinen erfolgreichen Commit.
Zugangsdaten, personenbezogene Informationen und sensible Nachrichteninhalte gehören nicht in solche Diagnoseprotokolle. Auch IDs können Maskierung oder Zugriffsbeschränkungen erfordern. Innerhalb eines Prozesses hilft Objektidentität beim Unterscheiden von Referenzen; eine PHP-Objekt-ID ist aber kein dauerhafter prozessübergreifender Bezeichner.
Die erste Frage bleibt konkret: Welcher EntityManager, falls überhaupt einer, verwaltet diese Instanz gerade? Danach lässt sich prüfen, ob die Änderung erfasst wurde und ihre Transaktion abgeschlossen ist. So wird aus dem beobachteten Speicherzustand eine überprüfbare Aussage über Persistenz, ohne vorsorgliche persist()-Aufrufe und wiederholte Flushes.
Technische Dokumentation
- Doctrine ORM: Entity-Lebenszyklus und Identity Map — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/working-with-objects.html
- Doctrine ORM: Strategien zur Änderungsverfolgung — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/change-tracking-policies.html
- Doctrine ORM: Transaktionen und Fehlerbehandlung — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- Symfony 7.4: Lebenszyklus von Messenger-Workern — https://symfony.com/doc/7.4/messenger.html
