Skip to content

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.

  1. Entities are persist()-ed or loaded via find() / a repository, entering the IdentityMap as MANAGED.
  2. Property mutations are tracked by the ChangeAggregator against a change-tracking snapshot taken at load/persist time.
  3. flush() wraps all pending work in one transaction and computes the minimal set of INSERT / UPDATE / DELETE statements.
  4. On success, the transaction commits and in-memory state (generated IDs, bumped version columns) is reconciled with what was written.
  5. 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.
  • 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.
  • EntityManager combines 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.

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.