Sharing Entity Data Across EntityManager Instances with Doctrine's Second-Level Cache
Doctrine's second-level cache shares serialized entity data across EntityManager instances. This guide covers configuration, mapping, verification, and common pitfalls.
07 Mar 2026, 12:58 UTC

The repeated-query problem
Every Doctrine EntityManager maintains a first-level cache—also called the identity map—that guarantees a single entity instance per database row within that manager. The limitation is scope: when a second EntityManager opens in the same request, or when a later request starts fresh, the identity map is empty and Doctrine issues another SQL query for the same identifier. In read-heavy applications this multiplies database round-trips for data that rarely changes.
The second-level cache addresses this by storing serialized entity data outside the EntityManager in a shared cache region. Once enabled, a hydrated entity read by one manager can be reused by another without hitting the database, provided the underlying cache driver supports cross-request persistence.
How the cache is wired
Second-level caching is opt-in and configured at the ORM level. You enable it in the Doctrine configuration array and point it at a cache driver. The driver determines whether cached data survives beyond a single PHP request.
use Doctrine\Common\Cache\FilesystemCache;
$config = new \Doctrine\ORM\Configuration();
$config->setSecondLevelCache(true);
$config->setSecondLevelCacheDriver(
new FilesystemCache(__DIR__ . '/var/cache/doctrine')
);
The directory passed to FilesystemCache must be writable by the PHP process. Redis or Memcached drivers are common choices when multiple application servers need to share the same cache pool.
Marking entities as cacheable
Entities do not enter the second-level cache automatically. You annotate them with @Cache, specifying a concurrency strategy and an optional region name. The region groups related entries so they can be evicted or monitored together. The same settings can be expressed in XML or YAML mapping files if annotations are not used.
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @Cache(usage="NONSTRICT_READ_WRITE", region="product_region")
*/
class Product
{
/** @ORM\Id @ORM\Column(type="integer") @ORM\GeneratedValue */
private $id;
/** @ORM\Column(type="string") */
private $name;
}
The usage attribute controls how Doctrine handles concurrent updates. NONSTRICT_READ_WRITE allows stale reads under contention but avoids locking overhead; READ_WRITE uses a locking protocol for stronger consistency at the cost of additional cache operations.
Caching DQL query results
Entity-level caching does not automatically cache the results of DQL queries. To cache a result set, add the Query::HINT_CACHEABLE hint. Doctrine then stores the list of identifiers returned by the query and reuses it for an identical query, fetching the entities themselves from the second-level cache.
$query = $em->createQuery(
'SELECT p FROM App\Entity\Product p WHERE p.category = :cat'
);
$query->setParameter('cat', 'tools');
$query->setHint(\Doctrine\ORM\Query::HINT_CACHEABLE, true);
$products = $query->getResult();
Without this hint, the query executes every time even if every returned entity is already cached.
Verifying that the cache works
A practical check uses two EntityManager instances and the SQL logger to confirm that the second load produces no database query.
- Create the first
EntityManager, load an entity by identifier, and inspect the SQL logger: one SELECT should appear. - Create a second
EntityManagerusing the same cache driver and load the same identifier. - Inspect the SQL logger again. If the second-level cache is active, no SELECT is issued.
- Inspect the cache directory (or Redis keyspace) for a serialized entry keyed by the entity class and identifier.
If SQL still appears on the second load, verify that the entity is annotated with @Cache, that the region name matches, and that the cache driver is shared between both managers.
Limits and common mistakes
Stale data after external writes
The cache cannot know about changes made outside Doctrine. A raw SQL UPDATE or a background job writing directly to the table leaves the serialized entity untouched. Doctrine invalidates the entry when it flushes its own changes, but external writers require manual eviction or a time-to-live setting on the cache driver.
Serialization and associations
Not every cache driver can serialize a full entity graph. Bidirectional associations, lazy proxies, and closures may fail or bloat the entry. If you encounter serialization exceptions, limit the cached fields or ensure the driver supports the object graph you are storing.
Unbounded growth
Enabling the cache without an eviction policy can consume all available memory or disk. Set a maximum number of entries or a TTL on the driver, and monitor cache size in production.
Composite and custom identifiers
Cache keys are derived from the entity class name and its identifier. Composite primary keys or custom identifier generators can produce collisions unless you supply a custom cache key generator. Test these entities carefully before enabling them.
When to avoid it
Second-level caching adds operational complexity. It is a poor fit for write-heavy tables where rows change on every request, or for applications that require strict read-after-write consistency across managers. For those cases, the first-level cache and database indexes are usually sufficient.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.