· 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.

A closed Doctrine Entity Manager halting a Symfony application's workflow

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:

  1. A flush() failed. With implicit transaction demarcation, Doctrine opens a transaction inside flush(), and if anything in it throws, it rolls back and closes the manager before rethrowing.
  2. wrapInTransaction() caught an exception. Same contract, made explicit: it flushes before commit, and on exception it rolls back and closes.
  3. 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.

no

yes

flush()

driver error?

commit, manager stays open

rollback

managed entities become detached

closed flag set

every later call throws EntityManagerClosed

re-persisting a detached entity inserts a duplicate

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_logger

doctrine_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=3600

The 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 EntityManagerInterfaceYes, under SymfonyThe proxy re-points; a copied local variable does not
Managed entitiesNoDetached at rollback, reload by identifier
Pending persist() callsNoThe UnitOfWork is discarded, re-issue them
Open DBAL transactionNoRolled back before the flag is set
Second-level cache entriesNoCleared with the manager
The condition that caused the failureYesA 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_connection on 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#

author

Krzysztof Słomka

Senior Backend Engineer & Software Architect. Writing about backend architecture, DDD, Event Sourcing, distributed systems and AI engineering.

linkedin · github

$ related posts