Unit of Work & Identity Map
EntityManager tracks entity state (NEW → MANAGED → REMOVED) via UnitOfWork. Changes are collected by a ChangeAggregator and flushed as a batch. Each context (EntityManager instance) has its own IdentityMap — no process-wide singletons.
Lifecycle of a flush
Section titled “Lifecycle of a flush”- Entities are
persist()-ed or loaded viafind()/ a repository, entering theIdentityMapasMANAGED. - Property mutations are tracked by the
ChangeAggregatoragainst a change-tracking snapshot taken at load/persist time. flush()wraps all pending work in one transaction and computes the minimal set ofINSERT/UPDATE/DELETEstatements.- On success, the transaction commits and in-memory state (generated IDs, bumped version columns) is reconciled with what was written.
- On any throw, the transaction rolls back. In-memory mutations that happened inside the flush loop must be revertable, or the entity is left poisoned relative to the (reverted) database state.
Memory-efficient unit of work
Section titled “Memory-efficient unit of work”- Clear entities from memory that are no longer needed within specific operations via
.$em->clear() - Different units of work can track their own entities independently.
EntityManagercombines all unit-of-work changes into minimal database queries during flush.
Two ways to keep a bounded sub-operation from growing the main identity map:
// Option A: scoped UnitOfWork for a bounded sub-operation, cleared right after$scope = $em->createUnitOfWork();$em->setActiveUnitOfWork($scope);// ... persist/find work for this sub-operation ...$em->flush();$em->removeUnitOfWork($scope);
// Option B: keep entities that must survive in the main UoW, clear only the scratch one$mainUow = $em->getActiveUnitOfWork();$scratch = $em->createUnitOfWork();$em->setActiveUnitOfWork($scratch);// ... iterate, persisting only what should be discarded after this pass ...$em->setActiveUnitOfWork($mainUow);Option A suits a self-contained batch step; option B suits a long-running process that wants to retain a small set of entities across many short-lived passes. Either way, flush() on the EntityManager still commits pending work from every registered UnitOfWork in one transaction.
Useful for processing large datasets, complex business operations spanning multiple contexts, and long-running processes with varying entity lifecycles.
Read replicas via separate contexts
Section titled “Read replicas via separate contexts”There’s no built-in read/write routing — intentionally. Let infrastructure handle it (PgBouncer, ProxySQL, RDS Proxy), or use the per-context design directly:
// Write context → primary$primary = new EntityManager($primaryConnection, ...);$primary->persist($entity);$primary->flush();
// Read context → replica$replica = new EntityManager($replicaConnection, ...);$users = $replica->findAll(User::class);Each EntityManager owns its IdentityMap and UnitOfWork — calling flush() on a replica-backed instance is a design smell, not a supported write path.
See also
Section titled “See also”- Performance guide — identity map, second-level cache, and batch iteration in practice.
- Optimistic Locking guide — recovery after a flush conflict without a poisoned unit of work.