Blog
Doctrine UnitOfWork — When Entities Stop Behaving as Expected
An entity changes in PHP, yet the database remains unchanged. Very often the problem is not the setter, but the state of that exact object inside the current EntityManager.
A bug that looks impossible
An order reports that it is paid, flush() returns normally, yet its database row has not changed. One possible explanation is that the PHP object outlived the persistence context that was tracking it.
This illustrative example assumes an existing unpaid order, an ordinary mapped status field and a repository using the same open EntityManager as the rest of the code. markAsPaid() changes that field; it does not perform its own SQL or re-register the object.
$order = $orderRepository->find($id);
if ($order === null) {
throw new OrderNotFound($id);
}
$entityManager->clear();
$order->markAsPaid();
$entityManager->flush();OrderNotFound is a project exception, not a Doctrine class. The omitted entity and repository are examples, not a report of a GiSoft production incident.
The call to clear() detaches the loaded order. It does not destroy the PHP object or reset its properties, so $order->getStatus() can subsequently return paid. However, the later flush() does not schedule an update for that detached instance. Other work registered after clear() could still be flushed.
In this example, the object and its persistence status diverge:
Operation PHP status Entity state Status write
find() unpaid MANAGED —
clear() unpaid DETACHED —
markAsPaid() paid DETACHED —
flush() paid DETACHED no UPDATEA useful first check is:
dump($order->getStatus());
dump($entityManager->contains($order));Here the values are paid and false. The second value means this EntityManager does not currently manage the instance according to contains(). It is not, by itself, proof of DETACHED: a new, unpersisted object or one scheduled for removal can also return false. An instance managed by another EntityManager is not automatically managed here either. In the opening example, we know it is detached because we know how it was loaded and then cleared.
What UnitOfWork tracks
UnitOfWork holds the EntityManager's bookkeeping for entity instances: identities, tracked values and pending entity or collection operations. With Doctrine's default DEFERRED_IMPLICIT change-tracking policy, it compares mapped values during flushing to determine the required writes. It is not a search through every PHP variable or service property.
Tracking is configurable. With DEFERRED_EXPLICIT, managed entities must be selected for change detection through persist() or an appropriate cascade. Read-only mappings, association ownership and the kind of value being changed also matter. Being managed is necessary for ordinary tracked updates, but it does not mean every method call produces an UPDATE.
The four lifecycle states describe the instance relative to a persistence context:
State Meaning in this context
NEW A new entity, not yet managed;
its INSERT has not yet been scheduled.
MANAGED Associated with this EntityManager, not REMOVED.
Tracking follows the mapping and policy.
DETACHED Represents a persistent identity, outside
this EntityManager's management.
REMOVED An existing entity scheduled for deletion
during flush().A newly persisted object can be managed before its row has been inserted. Conversely, an identifier on an object does not establish that this EntityManager manages it. Lifecycle state, PHP values and database contents are related, but they are not interchangeable.
clear() and the Identity Map
clear() empties the EntityManager's UnitOfWork, including pending changes. Unflushed work is no longer scheduled for persistence. Existing PHP references remain usable, and clearing does not commit or roll back a database transaction. It also does not close the EntityManager: it can load another set of managed objects afterwards.
The Identity Map explains why retaining the old reference matters. Assuming user 42 exists and there is no clear, detach or manager replacement between these calls:
$userA = $entityManager->find(User::class, 42);
$userB = $entityManager->find(User::class, 42);$userA === $userB is true. Within that context, Doctrine associates a persistent identity with one managed instance; the second find() need not read the database again. After clear(), loading the same identity can yield another instance. The earlier object does not become managed merely because its identifier matches.
An import can create a similar ambiguity:
$userFromDatabase = $userRepository->find(42);
$userFromPayload = User::fromImportedPayload($payload);User::fromImportedPayload() is a hypothetical project factory. For this example, assume it creates a separate object from validated input intended to describe user 42. Its name proves neither the assigned identifier nor its Doctrine state; those depend on its implementation and mapping. Even with the same identifier, it is not automatically the instance returned by the repository. Apply permitted import changes to the managed entity instead of treating an arbitrary reconstructed object as a database update.
persist(), flush() and the correction
For a genuinely new mapped entity, the familiar pair has two distinct responsibilities:
$entityManager->persist($entity);
$entityManager->flush();persist() makes that new instance managed and schedules insertion; flush() performs the pending writes. This does not guarantee that persist() performs no database work at all: identifier generation or callbacks may do work before the insert.
An existing managed entity using the default tracking policy normally needs no additional persist() after a field change. The explicit tracking policy described above is a separate case. Calling persist() on a detached entity is not a general reattachment operation: Doctrine can treat an unknown object as new, leading to insertion attempts or errors rather than the intended update.
Older ORM 2 examples may use merge() to copy detached state into a managed instance. That operation did not simply turn the original object into the managed one. Merge support was removed in ORM 3; the method is absent in ORM 3.6. It is not a current recovery recipe.
For the detached-order case, use a repository bound to the current, open EntityManager, load the order and apply the intended operation to the returned object:
$order = $orderRepository->find($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
$order->markAsPaid();
$entityManager->flush();This reload is not an instruction to copy every property from the stale object. Re-evaluate the business operation, permissions and expected version where relevant. If concurrent changes matter, use the application's concurrency controls, such as optimistic locking. find() can reuse an already-managed object or a configured cache; it does not universally guarantee the latest committed values.
The excerpt assumes the caller owns this flush, with no intervening clear or manager replacement. Reloading into a different EntityManager and flushing the old one would leave the underlying mistake unresolved.
Messenger, retained entities and serialisation
A conventional PHP-FPM request releases its request-local objects when execution ends. A long-running Messenger process can reuse services across many messages. A property can therefore retain an entity after its original persistence context has been cleared or replaced:
final class CustomerContext
{
private ?Customer $customer = null;
public function remember(Customer $customer): void
{
$this->customer = $customer;
}
public function customer(): ?Customer
{
return $this->customer;
}
}This is deliberately an example of risky retained state; it includes no reset behaviour. If CustomerContext survives while Doctrine's context changes, customer() can return an old instance. If the service itself is reset or recreated, that particular retention path may disappear.
Symfony provides service-reset mechanisms, but their operation depends on the Symfony and DoctrineBundle versions, reset configuration, worker options and the application's services. Do not assume every worker clears or replaces every EntityManager in exactly the same way after each message. Where stateful services are necessary, integrate their cleanup with the configured reset lifecycle and test successive messages.
Messages should normally carry identifiers or deliberate DTOs rather than live ORM entities. These are alternative conceptual message designs, not two constructor signatures for one implementation.
An entity-bearing message:
new GenerateInvoiceMessage($invoice);An identifier-bearing message:
new GenerateInvoiceMessage($invoiceId);The second design gives the handler enough information to load the invoice through its current repository:
$invoice = $invoiceRepository->find($message->invoiceId);
if ($invoice === null) {
throw new InvoiceNotFound($message->invoiceId);
}GenerateInvoiceMessage, its invoiceId property and InvoiceNotFound are project-owned examples. The handler must still handle deletion, authorisation and changed business state since dispatch. Sending an ID does not itself make a retry or an external side effect safe.
Serialising an entity to a queue, session or cache does not transport its membership of an EntityManager. Reconstructing an object is not registering it with the receiving UnitOfWork. This does not mean that merely creating a message synchronously detaches its argument, or that serialising an object detaches the original reference still held by the sender.
Lazy-loading behaviour also varies with the ORM version, mapping, proxy mechanism and serialiser. An unloaded association may fail after transport or still invoke a retained loader after a clear; already-loaded values may remain readable. Neither successful access nor an exception establishes that the owner is managed. Load the required data in the operation's current context instead of relying on a detached object's associations to repair it. Making every relation eager does not reattach the object and may load unnecessary data.
Who owns the transaction?
Consider a controller calling four services: A flushes, B clears the EntityManager, C modifies the previously loaded order, and D flushes again. The important question is not how many services exist, but whether their combined behaviour has an agreed persistence and transaction boundary.
Keep four things distinct: the lifetime of the EntityManager, UnitOfWork tracking, execution of SQL, and the outer database commit or rollback. Without an explicit enclosing transaction, a flushing operation with writes normally uses Doctrine's implicit transaction handling. Inside an explicit transaction, a successful flush() does not establish that the outer transaction has committed.
An application service, command handler or configured transaction middleware can own that boundary. Symfony's doctrine_transaction middleware can flush and commit after the handlers complete; calling a handler directly in a test bypasses that middleware. Document who owns flushing before adding another call inside a nested service.
A rollback does not restore the PHP properties to their earlier values. Certain failures during flushing close the EntityManager; clear() does not reopen it. Recovery must use the application's manager-reset mechanism and discard stale entity references, not retry blindly with the same closed manager. Multiple boundaries are reasonable for batches, provided partial progress and recovery are intentional.
Batches without carrying old objects forward
An import can flush and clear periodically to limit the number of managed objects. This is a control-flow sketch: the comment stands for omitted row validation and entity creation or updates, including persist() for new entities.
$processed = 0;
foreach ($rows as $row) {
// Validate the row; create or update entities.
if (++$processed % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();The counter does not depend on the input's array keys. The final flush handles a trailing partial batch; the final clear releases UnitOfWork references. Both the code processing the next row and any related services must avoid reusing entities retained before a clear.
For existing orders, the same approach can carry IDs and load within the active context:
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 is an integer and $orderIds is an iterable of valid identifiers. This example deliberately skips missing orders; another workflow may need to record or reject them. The counter counts processed orders rather than input positions, so missing records cannot bypass a batch boundary. The final flush is harmless if the last batch was already flushed and no further work is pending.
Both examples assume a dedicated, open EntityManager, ordinary implicit change tracking and no enclosing transaction or middleware that changes these boundaries. Each successful write batch may therefore commit separately; a later failure does not undo earlier committed batches. Exceptions stop these sketches. Checkpointing, bounded retries, idempotency and recovery are omitted, not implicitly provided.
Clearing reduces Doctrine's retained state, not necessarily all process memory. An input array, service property, logger or the last $order variable can still hold objects. Stream large ID inputs where appropriate and measure memory; do not simply remove clear() to hide detachment.
Diagnose context, tracking and transaction separately
When the first contains() check does not settle the cause, collect evidence at distinct layers. These are separate checks, not a chain in which one successful step proves the next:
- Establish which repository loaded the instance and which EntityManager is flushed. Trace clear, close, reset and retained service references.
isOpen()checks whether the manager is open, not whether it manages this object. - Check the mapped field, tracking policy, read-only configuration and owning side of changed associations. Lifecycle listeners may also alter or undo a value.
- Observe the relevant SQL using profiling or DBAL middleware supported by the installed versions. Separate “no update scheduled” from an executed write followed by rollback or an exception.
- Confirm the outer transaction's outcome and the database, tenant and read connection being inspected. Cache, replica lag or another writer can explain a different observed value.
When a fuller state distinction is needed, ORM 3.6 exposes this diagnostic API:
$state = $entityManager->getUnitOfWork()->getEntityState($order);It returns a Doctrine\ORM\UnitOfWork::STATE_* constant. For an unknown instance, distinguishing new from detached may require a database lookup. Use it as supporting evidence alongside the object's history, not as a business rule.
A changeset inspection is timing-sensitive: before normal change computation it may be empty, and after a successful flush it may have been cleared. An empty changeset alone therefore proves neither detachment nor a failed update. Avoid changing UnitOfWork internals merely to make the diagnostic show a desired result.
Test the database effect as well as the object
A unit test can establish that an unpaid order changes its PHP state:
$order->markAsPaid();
expect($order->isPaid())->toBeTrue();It says nothing about persistence. An integration test needs a persisted unpaid fixture and a reload that does not reuse that object:
$orderService->markAsPaid($orderId);
$entityManager->flush();
$entityManager->clear();
$reloadedOrder = $orderRepository->find($orderId);
expect($reloadedOrder?->isPaid())->toBeTrue();Here OrderService::markAsPaid($orderId) changes a managed order but does not flush, clear or own a transaction; the test deliberately supplies the flush. If the real service owns the commit, test that contract without a redundant flush. If middleware owns it, exercise the bus or reproduce that boundary explicitly.
The repository, service and test must use the intended EntityManager and database. Clearing removes first-level identity-map reuse, but does not itself disable result or second-level caches. With those caches controlled, the reload can verify the state visible in the test transaction. A test wrapped in an outer rollback transaction does not prove that a production commit was durable. That requires a suitable committed-write test and an independent read against the authoritative database.
The repeated detached-order example is more useful as a regression test than as another unexplained code listing. Starting again with a persisted, unpaid fixture:
$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();The assertions distinguish the old object's paid state from the unpaid state read into a different instance. These are Pest-style excerpts: fixture creation, test isolation, mappings and service wiring are omitted. They are not self-contained tests.
Useful production evidence
Log selected metadata rather than entire entities: a correlation ID, an appropriately protected entity identifier, message or command type, observed retry count, observed clear/reset events and the transaction outcome. SQL timings, worker memory and failed-message information can help where instrumentation is configured. Do not infer a successful commit just because a handler returned.
Avoid credentials, personal data and sensitive message bodies. Even identifiers can need redaction or restricted access. Within one process, object identity can help distinguish references, but a PHP object ID is not a durable cross-process identifier.
The useful starting question is precise: which EntityManager, if any, manages this particular instance now? Once that is established, check whether the change is tracked and whether its transaction completed. That separates an in-memory change from a saved business result without adding speculative persist() calls or repeated flushes.
Technical references
- Doctrine ORM: entity lifecycle and Identity Map — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/working-with-objects.html
- Doctrine ORM: change-tracking policies — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/change-tracking-policies.html
- Doctrine ORM: transactions and recovery — https://www.doctrine-project.org/projects/doctrine-orm/en/3.6/reference/transactions-and-concurrency.html
- Symfony 7.4: Messenger worker lifecycle — https://symfony.com/doc/7.4/messenger.html
