~/blog/the-hidden-cost-of-flush-in-a-symfony-application
The Hidden Cost of `flush()` in a Symfony Application
Doctrine’s flush() can affect much more than the entity a repository appears to save. This article looks at Unit of Work behavior, transaction boundaries, lifecycle listeners, batch processing, failed flushes, and how to make persistence boundaries easier to understand and operate safely.
- Published
- Reading time
- 6 min
A customer changes their email address.
The repository saves the customer and calls flush().
An order modified earlier in the request is written too. A listener runs. Another table is updated.
The method is called saveCustomer(), but its effect extends beyond the customer.
This is one of the first things I investigate when a Symfony application appears to persist changes “from somewhere else.”
flush() operates on the entity manager’s pending work. Its scope is not defined by the repository method that calls it.
The cost can be computational. It can also be architectural: hidden writes, lifecycle behavior, and transaction boundaries that are difficult to see from the calling code.
1. The method name does not define what gets written
Consider this repository method:
public function save(Customer $customer): void
{
$this->entityManager->persist($customer);
$this->entityManager->flush();
}Now consider the caller:
$order = $orders->get($orderId);
$order->markReadyForDispatch();
$customer = $customers->get($customerId);
$customer->changeEmail($newEmail);
$customers->save($customer);Assuming both entities are managed by the same entity manager with ordinary change tracking, the flush can write both changes.
Calling persist($customer) does not restrict the flush to that object. Doctrine tracks managed entities and synchronizes pending changes when flushing.
That behavior is useful when the changes belong to one operation.
It becomes surprising when a repository method appears to own only one write.
I look for methods named save(), update(), or create() and check whether they flush internally. Then I inspect the callers.
A more explicit arrangement might be:
$order->markReadyForDispatch();
$customer->changeEmail($newEmail);
// The application operation owns this persistence boundary.
$entityManager->flush();For already managed entities, calling persist() again is generally unnecessary.
I do not remove repository-level flushes mechanically. Some callers may depend on their timing. I establish those expectations first.
2. Flushing and committing are different questions
Without an existing explicit transaction, Doctrine normally wraps the writes performed by a flush in a transaction.
Inside an outer transaction, a successful flush does not establish that the outer transaction has committed.
That distinction matters here:
$entityManager->wrapInTransaction(
function ($entityManager) use ($order): void {
$order->confirm();
$entityManager->flush();
$this->mailer->sendConfirmation($order);
$this->ledger->recordOrder($order);
}
);If ledger processing throws, the database work can roll back after the email has already been sent.
The explicit flush did not make the confirmation permanent.
wrapInTransaction() itself flushes before committing, so an additional flush inside it should have a specific purpose.
Sometimes an intermediate flush is needed to obtain a generated identifier or surface a database constraint earlier. I still ask whether callers understand that the surrounding operation can fail afterward.
I also inspect lower-level services for hidden flushes:
$orders->create($input); // Flushes internally.
$inventory->reserve($order); // Flushes internally.
$discounts->recordUse($order); // Throws.Without an encompassing transaction, earlier writes may already be committed.
With an encompassing transaction on the same connection, the behavior is different.
I want to find that distinction in the code without reconstructing several layers of middleware and repository conventions.
3. Lifecycle behavior can make a flush much larger than it looks
The SQL statements are only part of the execution.
Doctrine lifecycle callbacks and listeners can run during flushing. Their permitted operations vary by event; changing entities inside a listener requires care about when change sets are calculated. Doctrine also warns against re-entering flush() from flush-related handlers.
When investigating an unexpected write, I inspect mappings and listener registration alongside the repository.
This kind of listener deserves particular scrutiny:
public function postUpdate(Order $order): void
{
if ($order->isConfirmed()) {
$this->gateway->createShipment($order);
}
}It raises several questions.
Does every update to an already-confirmed order create a shipment? Does an import invoke it? What happens if shipment creation succeeds and later database work fails?
An entity’s current status is not necessarily evidence that this flush performed the transition.
Moving the call to postFlush does not automatically solve the transaction problem. An outer transaction may still be active.
For an external effect that requires reliable delivery, I prefer an explicit business operation that records the change and a delivery intent together. An outbox is one option, provided its record participates in the same database transaction.
The delivery process then needs its own retry and duplicate-handling rules.
I am comfortable with lifecycle hooks for carefully scoped persistence concerns. I am much more cautious when they become the hidden orchestration layer for payments, shipments, or notifications.
4. A small change can sit inside a large unit of work
This loop is worth investigating:
foreach ($customers->findAll() as $customer) {
$customer->normalizeReference();
$entityManager->flush();
}It loads the full result set and repeatedly flushes while entities remain managed.
Flush cost depends partly on the size of the unit of work and the change-tracking policy. With ordinary implicit tracking, Doctrine examines managed state to identify changes.
Even if each iteration changes one customer, the surrounding managed state can be substantial.
For an independent maintenance operation, I might use bounded batches:
$processed = 0;
foreach ($customerQuery->toIterable() as $customer) {
$customer->normalizeReference();
if (++$processed % 100 === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
$entityManager->flush();
$entityManager->clear();The batch size is illustrative. I measure memory, duration, and database behavior before choosing it.
clear() detaches managed entities. The surrounding code must not assume that previously held objects remain managed. The query and association-loading strategy must also support iteration safely.
More importantly, batch boundaries change failure semantics.
Without an outer transaction, completed batches can remain committed if a later batch fails. The job needs a safe restart strategy.
With one transaction around the entire job, the transaction remains long-lived even though the entity manager is cleared periodically.
I do not choose batching solely to reduce memory. I also decide what partial completion means.
5. A failed flush changes the recovery plan
This is not a recovery strategy I would trust:
try {
$entityManager->flush();
} catch (Throwable $e) {
$logger->error($e->getMessage());
}
// Continue using the same entities and entity manager.Doctrine’s documented transaction-failure paths can close the entity manager. A rollback also does not restore PHP objects to their previous in-memory values.
An object may still say “confirmed” after its database update was rolled back.
Before retrying, I want a fresh persistence context and a decision about which business operation should be repeated. Merely resetting the manager does not make old entity references trustworthy.
This matters especially in long-running commands and workers, where an exception does not necessarily end the process.
For verification, I use database-backed tests that inspect persisted state through a fresh context. An assertion against the same object that was modified before the failed flush can tell the wrong story.
What I Ask When I See flush()
I usually pause at five questions:
- What else is currently managed or scheduled for persistence?
- Which callbacks and listeners can execute?
- Is there an outer transaction?
- What becomes durable if the next step fails?
- Who owns recovery if this flush throws?
The answer is sometimes straightforward: this is the deliberate end of one small database operation.
Other times, one line in a repository has quietly become the persistence boundary for half the request.
That is the hidden cost I want to make visible before changing where the flush happens.