Why Doctrine ORM Skips Updates After flush(): A UnitOfWork Diagnostic Guide
Doctrine flush() with no UPDATE? Check if your entity is managed, inspect the UnitOfWork change set, and verify mapping and transaction boundaries.
13 May 2026, 23:20 UTC

The Symptom: flush() Runs but Nothing Updates
You assign a new value to a Doctrine entity property, call EntityManager::flush(), and no error appears. Yet your SQL log shows no UPDATE statement and the database row is unchanged. In Doctrine ORM, this almost always means the UnitOfWork—the component that detects changes—did not compute a changeset for that object. The useful takeaway: inspect getUnitOfWork()->getEntityChangeSet($entity) immediately before flush(). If the array is empty, Doctrine sees either an unmanaged object or no difference worth writing.
How Doctrine Decides to Write
Doctrine does not monitor every object field continuously. When an entity is loaded or persisted, it enters the identity map and becomes managed. During flush(), the UnitOfWork compares the current state of each managed entity against the original snapshot taken at load time. If a property differs, Doctrine queues an UPDATE. If the entity is detached, new without a persisted identifier, or excluded by mapping, no comparison happens and no SQL is generated.
Cause and Diagnostic Table
| Symptom | Likely Cause | Diagnostic |
|---|---|---|
| Empty change set, no UPDATE | Entity detached or new | getUnitOfWork()->getEntityChangeSet($entity) returns empty array |
| Empty change set, mapping says read-only | Entity marked immutable/readOnly in mapping | Inspect metadata: $em->getClassMetadata(Foo::class)->isReadOnly() |
| Empty change set, DEFERRED_EXPLICIT policy | Change tracking requires manual notification | Check changeTrackingPolicy in annotation/XML/YAML |
| Non-empty change set, still no UPDATE | Transaction not committed, or listener discarding | Enable SQL logger; check onFlush listeners and transaction state |
Ordered Checks
1. Confirm the Entity Is Managed and Has a Changeset
Run this in a test script, a console command, or a debug endpoint. No elevated database permissions are required beyond normal read access.
<?php
use Doctrine\ORM\EntityManagerInterface;
/** @var EntityManagerInterface $em */
$uow = $em->getUnitOfWork();
$entity = $repository->find($id); // replace $id with the actual identifier
$entity->setName('Updated Name');
// Inspect BEFORE flush
$inScope = $uow->isInScope($entity); // true if managed
$changeSet = $uow->getEntityChangeSet($entity);
var_dump($inScope, $changeSet);
Expected check: $inScope is true and $changeSet contains the modified field. If $changeSet is empty, Doctrine does not see the entity as managed or sees no change.
Risk: Calling getEntityChangeSet after flush() may return an empty array because changesets are cleared after synchronization.
2. Review Mapping Metadata
An entity can be excluded from change tracking by mapping metadata. Check whether it is marked read-only or uses an explicit tracking policy.
<?php
$metadata = $em->getClassMetadata(Product::class);
$isReadOnly = $metadata->isReadOnly();
$policy = $metadata->getChangeTrackingPolicy();
Expected check: $isReadOnly is false. The default policy is usually DEFERRED_IMPLICIT. If the policy is DEFERRED_EXPLICIT, you must notify the UnitOfWork manually.
Version note: Metadata method names are generally stable between Doctrine ORM 2.x and 3.x, but some constants and internal APIs may differ. Verify against your installed version.
3. Inspect Transaction Boundaries and Event Listeners
If the changeset is non-empty but no UPDATE appears, the problem may be outside the UnitOfWork. Confirm whether you are inside a transaction and whether an onFlush listener is interfering.
<?php
$connection = $em->getConnection();
$connection->beginTransaction();
try {
$entity->setName('Updated Name');
$em->flush();
$connection->commit();
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
}
Expected check: The SQL logger shows an UPDATE after flush() and the row persists after commit(). If you use an external transaction manager, verify that a commit actually reaches the database connection.
Fixes Tied to Findings
If the Entity Is Detached
A detached entity has a valid identifier but is no longer tracked by the EntityManager. This commonly happens after EntityManager::close(), deserialization, or when constructing an object manually with an existing ID.
Fix: Re-attach the object with merge(), or re-fetch it through the repository, modify the managed copy, and flush.
<?php
// Option A: merge returns a NEW managed copy
$managed = $em->merge($detachedEntity);
$managed->setName('Updated Name');
$em->flush();
// Option B: re-fetch by identifier
$managed = $repository->find($detachedEntity->getId());
$managed->setName('Updated Name');
$em->flush();
Caution: After merge(), do not continue mutating the original detached object. Only the returned managed instance is tracked.
If the Entity Is Read-Only or Immutable
Remove readOnly=true from the mapping if the row must be updatable. If the entity is intentionally immutable, persist changes by inserting a new record rather than updating the existing one.
If the Policy Is DEFERRED_EXPLICIT
Under this policy, Doctrine does not compare properties automatically. After modifying the entity, explicitly recompute the changeset:
<?php
$em->getUnitOfWork()->recomputeSingleEntityChangeSet($entity);
Alternatively, switch the mapping to DEFERRED_IMPLICIT (the default) or the NOTIFY policy if you need finer control.
If a Listener or Transaction Blocks the Write
Review any onFlush event listeners that call clear(), detach entities, or suppress updates. If the changeset is correct but SQL is still absent, enable the SQL logger to confirm whether Doctrine generates the statement at all.
Escalation Criteria
Escalate the investigation when:
- The change set is non-empty, the SQL logger shows an
UPDATE, but the database row remains unchanged. This suggests a rollback, a wrong connection, or a replication lag issue. - Multiple
EntityManagerinstances or database connections are involved and you cannot tell which one owns the managed entity. - Bulk DQL updates have been executed, invalidating in-memory instances and making the identity map stale.
Practical Verification
Enable Doctrine's SQL logger and run a script that modifies a managed entity, then calls flush(). Confirm that an UPDATE statement appears in the query log.
<?php
use Doctrine\DBAL\Logging\DebugStack;
use Doctrine\DBAL\Logging\Middleware;
$logger = new DebugStack();
$em->getConnection()->getConfiguration()->setMiddleware(new Middleware($logger));
$entity = $repository->find($id);
$entity->setName('Verified Name');
$em->flush();
foreach ($logger->queries as $query) {
if (stripos($query['sql'], 'UPDATE') === 0) {
// Confirm the expected table and bound parameters
}
}
If the log contains the UPDATE but the row is unchanged after the script ends, verify that the transaction was committed and that you are inspecting the same database connection.
Limitations and Cautions
- Version differences: Doctrine ORM 2.x and 3.x share the
getUnitOfWork()API, but metadata access and internal behavior may vary. Check the documentation for your installed version. - Bulk operations: DQL
UPDATEorDELETEstatements bypass the UnitOfWork and identity map. In-memory entities become stale and will not reflect the bulk change until reloaded. - flush() vs. commit:
flush()synchronizes state to the database within the current transaction. It does not commit. Uncommitted changes roll back when the connection closes. - merge() semantics:
merge()returns a managed copy. The original detached object remains detached and further mutations to it are ignored.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.