· 10 min read · updated
The EntityManager Is Closed: Causes, Reset, and Recovery
The EntityManager is closed. The message names the symptom and hides the cause, which is why the
first answer you find tells you to call resetManager() and move on. That answer works often enough
to stop people looking, and it leaves two bugs standing: the entities you were holding are no longer
attached to anything, and whatever closed the manager the first time is still there to close it
again.
Doctrine does not close the manager to annoy you. It closes it because the in-memory UnitOfWork and
the database have stopped agreeing, and every subsequent flush() would write that disagreement
down.
This article walks the recovery in order: what the closed flag protects, why your entities
come back detached rather than stale, what a ManagerRegistry reset restores and what it silently
does not, why the "log the failure to the database" pattern closes the manager a second time, how
transaction scope stops the closure from happening at all, and which Messenger middleware belongs on
a worker that has to survive a bad message at 3am.

The failure is loud in the logs and quiet in the code: nothing looks wrong at the call site.
What the closed flag protects#
The EntityManager carries a boolean. Once it flips, persist(), flush(), find() and the rest
throw EntityManagerClosed on every call, for the lifetime of that instance. There is no reopen
method. That is deliberate.
The flag flips in three situations, and only three are worth remembering:
- A
flush()failed. With implicit transaction demarcation, Doctrine opens a transaction insideflush(), and if anything in it throws, it rolls back and closes the manager before rethrowing. wrapInTransaction()caught an exception. Same contract, made explicit: it flushes before commit, and on exception it rolls back and closes.- Something called
close(). Rare in application code, common in library and middleware code you did not write.
Notice what is absent from that list. A lost database connection does not close the
EntityManager by itself, it makes the next query fail, and the failed flush() closes it. The
distinction matters when you are reading a stack trace, because the fix for a dropped connection sits
in a completely different place from the fix for a constraint violation.
The state you lost is not stale, it is detached#
This is the part that turns a one-line fix into an afternoon. When the transaction rolls back, every entity the manager was managing becomes detached. Not stale. Detached. Their properties still hold whatever they held at the moment of the rollback, which means they look completely fine in a debugger and are out of sync with the database in ways nothing will tell you about.
Two consequences follow, and the second one bites harder. Passing a detached entity to a fresh
manager's persist() does not update the row it came from, it schedules a new insert. And a detached
entity holding a generated identifier will happily carry that identifier into a second insert, which
turns a recoverable failure into a constraint violation on a different table.
After a rollback, discard the objects. Reload them by identifier.
Rung one: isOpen() tells you almost nothing#
The defensive check is the first thing everyone reaches for:
if (!$this->entityManager->isOpen()) {
// now what?
}It answers the wrong question. By the time isOpen() returns false, the UnitOfWork is already
gone, the transaction is already rolled back, and the work you wanted to do has already failed. The
check tells you that you are in the failure path. It does not tell you what to do about it, and it
does not give you back anything you lost.
isOpen() earns its place in exactly one spot: a guard at the top of a long-running loop, deciding
whether this iteration should reset before it starts. Everywhere else it is a symptom check standing
in for a fix.
Rung two: resetManager() returns a new manager, not your old one#
In Symfony, recovery goes through the registry rather than the manager, because a closed manager cannot repair itself:
use Doctrine\Persistence\ManagerRegistry;
public function __construct(
private readonly ManagerRegistry $registry,
private readonly EntityManagerInterface $entityManager,
) {
}
private function reset(): void
{
$this->registry->resetManager();
}Three details in eight lines are easy to get wrong.
The return type is ObjectManager, not EntityManagerInterface. ManagerRegistry::resetManager()
is declared on the doctrine/persistence interface, which knows nothing about the ORM. If you assign
its return value and then call an ORM-only method on it, static analysis will reject the call and it
will be right to. Either narrow it deliberately, or ignore the return value and keep using the
injected EntityManagerInterface, which is the option I take.
The injected EntityManagerInterface keeps working after the reset, in Symfony. DoctrineBundle
registers entity managers behind a lazy proxy, so the reset swaps the instance the proxy points at
and your constructor-injected property follows it. Outside Symfony, or in any service that copied the
manager into a local variable before the failure, that is not true, and you keep a reference to a
manager that is closed forever.
The reset is global to that manager name. It does not scope to your service, your request or your message. Any other code holding managed entities loses them at the same moment, which is a real consideration inside a request handling several units of work and almost none inside a worker handling one message at a time.
To be precise about the scope of all of this: a reset restores your ability to talk to the database. It restores nothing about the work that failed. Retrying is a decision you make with knowledge a generic recovery helper does not have.
Rung three: the logging trap closes the manager twice#
Here is the pattern that generates most of the searches that lead to this page. Something fails, and the handler tries to record the failure in the same database that just rejected the write:
// BAD: the persist runs on a manager that is already closed, and the original
// exception is replaced by EntityManagerClosed, so the real cause never reaches the log.
try {
$this->entityManager->persist($payment);
$this->entityManager->flush();
} catch (\Throwable $exception) {
$payment->markFailed($exception->getMessage());
$this->entityManager->flush();
}The catch block runs, the manager is closed, flush() throws, and the exception that surfaces is
EntityManagerClosed. The constraint violation that started it is gone. You are now debugging the
error handler.
The order that works: log outside the database first, then reset, then reload, then write the failure record.
// GOOD: the cause reaches a log that cannot be closed, and the failure record is
// written by a manager that was reset before it was used.
try {
$this->entityManager->persist($payment);
$this->entityManager->flush();
} catch (\Throwable $exception) {
$this->logger->error('Payment persist failed', [
'payment_id' => (string) $payment->id(),
'exception' => $exception,
]);
$this->registry->resetManager();
$fresh = $this->entityManager->find(Payment::class, $payment->id());
if ($fresh === null) {
return;
}
$fresh->markFailed($exception->getMessage());
$this->entityManager->flush();
}Two things changed and both are load bearing. The logger writes to stderr, not to Doctrine, so it
survives a database that is refusing writes. And $payment is never touched again after the
rollback; the failure is recorded against $fresh, which the new manager actually manages.
The null check is not defensive padding. If the original flush() failed on the insert, the row was
never written, there is nothing to reload, and marking it failed is meaningless.
Rung four: scope the transaction so it never closes#
Everything above is recovery. Recovery is the expensive path, and most closed managers are avoidable by narrowing what a single unit of work is responsible for.
$this->entityManager->wrapInTransaction(function (EntityManagerInterface $em) use ($command): void {
$loan = $em->find(Loan::class, $command->loanId, LockMode::PESSIMISTIC_WRITE);
$loan->disburse($command->amount);
});The block does one thing, holds a lock for as short a time as possible, and gives Doctrine a boundary it can roll back cleanly. Compare that to a handler that writes four aggregates, calls a payment provider in the middle, and flushes once at the end: a timeout on the provider takes down all four writes and closes the manager, and none of the three successful writes were the problem.
Small transactions do not make failures rarer. They make the blast radius of a failure equal to the work you actually have to redo.
The cost is real and worth stating. Splitting one flush into three means three units of work that can partially succeed, which means you now need the boundaries to be idempotent. That is a trade, not a free improvement, and I have written elsewhere about building idempotent message handlers because it is the half of this trade nobody budgets for.
Rung five: long-running workers close for a different reason#
A web request builds a manager, uses it, and throws it away. A Messenger worker keeps one alive for
hours, which introduces a failure the request path never sees: the database drops an idle connection,
the next flush() fails on a connection that is gone, and the manager closes. The message that
triggered it was innocent.
Symfony ships middleware for exactly this. Register it on the bus the worker consumes:
# config/packages/messenger.yaml
framework:
messenger:
buses:
command_bus:
middleware:
# reconnects if the connection was dropped while the worker was idle
- doctrine_ping_connection
# closes the connection after each message instead of holding it open
- doctrine_close_connection
# logs a transaction that was opened and never closed
- doctrine_open_transaction_loggerdoctrine_ping_connection is the one that removes the idle-timeout failure. The other two are hygiene:
one stops a fleet of workers from pinning connections it is not using, and the other turns a silent
class of bug into a log line.
Pair it with worker limits, because the middleware handles connections and does nothing about memory:
$ php bin/console messenger:consume async --limit=100 --memory-limit=128M --time-limit=3600The worker exits and the supervisor restarts it with a fresh manager. This is not a workaround for a leak. It is the documented deployment shape, and treating a PHP worker as immortal is the actual mistake.
What survives a reset#
Survives resetManager() | Notes | |
|---|---|---|
Injected EntityManagerInterface | Yes, under Symfony | The proxy re-points; a copied local variable does not |
| Managed entities | No | Detached at rollback, reload by identifier |
Pending persist() calls | No | The UnitOfWork is discarded, re-issue them |
| Open DBAL transaction | No | Rolled back before the flag is set |
| Second-level cache entries | No | Cleared with the manager |
| The condition that caused the failure | Yes | A reset is not a retry strategy |
That last row is the one worth pinning to the wall.
Questions I ask in review#
When a pull request touches this area, four questions catch almost everything:
- Does any code path use an entity that was managed before the failure? If yes, it is a duplicate insert waiting for production traffic.
- Does the catch block write to the same database that just failed? If yes, the real exception never reaches a log.
- Is the transaction boundary smaller than the handler? If the handler is the boundary, an unrelated timeout takes down unrelated writes.
- If this runs in a worker, is
doctrine_ping_connectionon that bus? If not, the first quiet night is the first outage.
Two things this article does not cover, deliberately. It does not address multiple entity managers,
where resetManager('name') and per-manager middleware change every answer above. And it says
nothing about DBAL-level Connection handling outside the ORM, where the closed flag does not exist
and the failure modes are not the same ones.
Further reading#
$ related posts
15 min readNEW
The Read Side Does Not Go Through the Aggregate
CQRS read models in Symfony 8: a read port the domain does not own, a query bus that returns a value, and when a projection table earns its place. Part 3.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 3#cqrs#ddd#hexagonal-architecture#symfony#php#postgresql#doctrine15 min readNEW
The Domain Model, and the Only Rule That Matters
DDD domain model in PHP 8.5 and Symfony 8: keep the container out of the domain, and choose Doctrine ORM or DBAL with the costs on the table. Series part 1.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 1#ddd#hexagonal-architecture#symfony#php#doctrine#software-architecture18 min readNEW
The Application Layer, the Transaction, and a Working API
Application layer in Symfony 8: use cases on a Messenger command bus, the transaction boundary in config, optimistic locking and RFC 9457 errors. Part 2.
DDD, CQRS and Hexagonal Architecture in Symfony 8 · part 2#ddd#hexagonal-architecture#symfony#php#cqrs#messenger#software-architecture