Doctrine Second Level Cache in Production
AI generated
SF
{ }
Symfony · Doctrine ORM · Caching · Performance
Doctrine Second Level Cache in Production
Query performance without shortcuts

The Doctrine Second Level Cache reduces repeated database access by keeping hydrated entities in cache across requests. Configured properly with cache regions, concurrency strategies and a Redis adapter, database load drops noticeably without introducing consistency problems.

18 min read Second Level Cache · Cache Regions · Redis · PSR-6 Symfony 7 · Doctrine ORM 3

1. What the Second Level Cache actually solves

The Doctrine Second Level Cache operates at a different layer than the first level cache most developers already know. The first level cache, also called the identity map, exists only inside a single EntityManager, and therefore only for a single request. Once the request ends, the first level cache is gone too. The Doctrine Second Level Cache, in contrast, stores hydrated entity data in a persistent cache backend such as Redis or APCu, surviving across requests and sometimes even across server instances. That makes it a decisive building block whenever the same entities are read repeatedly but written rarely.

In practice this applies to many domains: product categories in a shop, country and tax tables, configuration values, roles and permissions. This data rarely changes but is read on almost every request. Without the Doctrine Second Level Cache, every one of these reads triggers a real database query, even if the record has not changed in hours. With the second level cache enabled, Doctrine returns the entity directly from the cache backend without touching the database at all. On high traffic endpoints the difference is not cosmetic, it often reduces database load by a double digit percentage.

An important distinction: the Doctrine Second Level Cache caches entity data, not query results in the sense of SQL result sets. That means different queries referencing the same entity benefit from the same cache entry. This property is what fundamentally separates it from the query cache, which we contrast later in this article. Using the Second Level Cache correctly requires understanding that it is an entity cache, not a result cache.

2. Activation in Symfony and Doctrine

Activating the Doctrine Second Level Cache in Symfony happens centrally through the Doctrine bundle configuration. By default the feature is disabled, because it has consequences for data consistency that not every project wants to accept. Activation happens via the orm.second_level_cache.enabled key, combined with a cache pool definition through Symfony's cache component. This separation is intentional: Doctrine handles the caching logic, Symfony's cache component handles the backend.

After basic activation, the Doctrine Second Level Cache needs at least one region configuration and a cache adapter. Without a cache adapter, Doctrine falls back to an array cache that only lives inside a single PHP process, which is practically useless in FPM environments with multiple workers. For production deployments, a shared backend like Redis is mandatory so that all PHP-FPM workers see the same cache state.


# config/packages/doctrine.yaml
doctrine:
    orm:
        auto_generate_proxy_classes: true
        second_level_cache:
            enabled: true
            region_cache_driver:
                type: pool
                pool: doctrine.system_cache_pool
            log_enabled: '%kernel.debug%'
            regions:
                default:
                    lifetime: 3600
                    cache_driver:
                        type: pool
                        pool: cache.app
                category_region:
                    lifetime: 7200
                    cache_driver:
                        type: pool
                        pool: cache.app

framework:
    cache:
        pools:
            cache.app:
                adapter: cache.adapter.redis
                provider: 'redis://redis:6379'

A common beginner mistake with the Doctrine Second Level Cache: the region configuration is set, but not a single entity is actually marked for caching. Without the #[Cache] attribute on the entity class, the second level cache remains technically enabled but practically useless, because Doctrine never creates cache entries for any entity. The next section shows exactly what this marking looks like.

3. Cache regions and concurrency strategies

A cache region in the Doctrine Second Level Cache is a logically separated area within the cache backend, with its own lifetime and its own concurrency strategy. Doctrine supports three concurrency strategies that determine how concurrent reads and writes are handled. READ_ONLY is the simplest and fastest strategy: once written, cache entries are never updated, only invalidated. It suits data that is practically static, such as country or currency lists.

NONSTRICT_READ_WRITE allows updates but does not guarantee strict consistency between cache and database during concurrent writes. For most use cases with moderate write frequency, this is a good compromise between performance and consistency. READ_WRITE is the strictest strategy in the Doctrine Second Level Cache: it uses soft locks to block competing reads during an update, guaranteeing that stale data is never read after a commit. This strategy costs more overhead but is necessary for entities with high write frequency and strict consistency requirements.

Choosing the right concurrency strategy for the Doctrine Second Level Cache is not a purely technical decision, it is a business one. A price field in a product catalog that gets updated several times a day through import jobs needs a different strategy than a category label that changes once a year. Ignoring this distinction and applying READ_WRITE to everything by default wastes performance for no business benefit.

4. Marking entities with the Cache attribute

For the Doctrine Second Level Cache to become active for a given entity, the class must be explicitly annotated with the #[Cache] attribute. The attribute can be set at the class level for the entire entity, but also on individual associations, such as a ManyToOne or OneToMany relationship. This granularity matters because not every association deserves the same caching strategy. An order should not be cached, but its associated category reference should be.


<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Cache(usage: 'READ_ONLY', region: 'category_region')]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private int $id;

    #[ORM\Column(length: 100)]
    private string $name;

    #[ORM\OneToMany(mappedBy: 'category', targetEntity: Product::class)]
    #[ORM\Cache(usage: 'NONSTRICT_READ_WRITE', region: 'category_region')]
    private Collection $products;

    public function getId(): int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }
}

An important rule applies to associations: the target entity must also be cacheable for the association itself to be cached. Doctrine throws an exception if a collection is marked cacheable while its elements are not annotated with #[Cache] themselves. This consistency check prevents the Doctrine Second Level Cache from producing half broken, contradictory cache states.

5. Redis as the Second Level Cache adapter

For production Symfony applications, Redis is the obvious adapter for the Doctrine Second Level Cache, because it is PSR-6 compatible, allows high throughput, and is already present in almost every deployment environment. Symfony's cache component provides a ready made adapter via cache.adapter.redis that only needs a connection DSN. It is important to use a dedicated Redis namespace or a dedicated database number for the second level cache, so that cache entries do not collide with session data or other cache pools.

A frequently overlooked aspect: the Redis adapter in the Doctrine Second Level Cache serializes entity data, not the entity objects themselves. Doctrine stores an internal snapshot of the field values and reconstructs a new entity object on a cache hit through the regular hydration pipeline. That means serialization cost occurs on every cache access, not only when writing. For very wide entities with many fields this overhead can become noticeable, which is why the Second Level Cache makes the most sense for compact, frequently read entities.

6. Query cache versus Second Level Cache

The query cache and the Doctrine Second Level Cache are often confused, but they solve different problems. The query cache stores the parsed, SQL translated DQL query, not the result data. It saves the cost of the DQL parser and query compiler for repeatedly executed queries with an identical structure but different parameters. The database query itself is still executed on every call with the query cache alone.

The Second Level Cache, on the other hand, can avoid the database query entirely if all referenced entities are already in cache. For maximum effect, combine both: the query cache reduces parsing overhead, the second level cache reduces the number of actual database accesses. A query can additionally be explicitly marked for the second level cache with $query->setCacheable(true), which caches the pure query result set as a region, independently of individual entity caches.

7. Invalidation and avoiding race conditions

Invalidation is the hardest discipline in the Doctrine Second Level Cache. For READ_WRITE regions, Doctrine uses a timestamp region that records the time of the last change for every region. When reading, Doctrine compares the cache entry timestamp with the region timestamp and automatically discards stale entries. This timestamp region must itself live in a persistent cache backend, otherwise invalidation does not work reliably after a process restart.

A race condition typically arises when two processes read and write the same entity concurrently while the Second Level Cache is configured with NONSTRICT_READ_WRITE. In that case, a stale value may briefly be read from cache before invalidation kicks in. For data where that is unacceptable, such as account balances or stock levels, READ_WRITE with soft locking is the right choice, even though it produces more cache overhead. Manual invalidation is possible via $cache->evictEntity() and $cache->evictEntityRegion(), for instance after bulk updates written directly via SQL, bypassing the ORM.

8. Monitoring and measuring cache hit rate

Without measurement, every claim about the benefit of the Doctrine Second Level Cache remains speculation. Doctrine provides a CacheLogger interface to count hits, misses and puts per region. In Symfony this logger can be registered as a service and its counters exported regularly to a monitoring system like Prometheus. A low hit rate on a region marked READ_ONLY almost always points to a configuration issue, for example a cache lifetime that is too short or a Redis adapter evicting entries prematurely.

The Symfony Profiler additionally shows in the Doctrine panel, when log_enabled is active, which queries were avoided thanks to the Second Level Cache. This view is invaluable during development to verify that the cache attributes are actually taking effect before relying on the performance improvement in production. A sensible rollout process enables the second level cache first for a few noncritical entities, measures the hit rate over several days, and expands coverage gradually.

9. Second Level Cache compared directly

The following table contrasts the different caching layers in Doctrine and shows when which approach makes sense. Choosing the right layer determines whether the Doctrine Second Level Cache actually removes load from the database or just adds complexity without a measurable effect.

Cache layer What gets cached Lifetime Typical use case
First Level Cache Identity map inside the EntityManager One request Automatic, no configuration needed
Query Cache Parsed DQL translated to SQL Until deploy or cache clear Many executions with the same structure
Doctrine Second Level Cache Hydrated entity data Region lifetime, e.g. 3600s Frequently read, rarely written entities
Result Cache Raw query result set Manually defined Individual, expensive reports or dashboards
Manual application cache Any computed value Freely chosen Aggregations, external API responses

This comparison leads to a clear recommendation: the Doctrine Second Level Cache is the right choice for entities with a clear read write imbalance. For one off, expensive aggregation queries, a manual application cache is often the more pragmatic solution, since it is not tied to the entity structure and can be invalidated more flexibly.

Mironsoft

Symfony performance, Doctrine tuning and caching architecture

Need a Doctrine caching strategy for your project?

We analyze your entity access patterns, configure cache regions with the right concurrency strategies, and set up a production ready Redis adapter for the Doctrine Second Level Cache.

Cache audit

Analysis of read write patterns and recommendation of fitting regions

Redis setup

Production ready configuration with monitoring and alerting

Performance tuning

Query analysis, hit rate optimization and regression tests

10. Summary

The Doctrine Second Level Cache solves a concrete problem: repeated database access to entities that rarely change. Activation requires three steps that must work together: the configuration in doctrine.yaml, a persistent cache adapter like Redis, and explicitly marking entities with the #[Cache] attribute. Without the last step the second level cache stays ineffective, even if every other part is configured correctly.

The choice of concurrency strategy, READ_ONLY, NONSTRICT_READ_WRITE, or READ_WRITE, should be justified by business needs rather than applied uniformly to every entity. Monitoring through the CacheLogger and the Symfony Profiler shows whether the Doctrine Second Level Cache is actually taking effect before relying on the performance improvement in production. Implementing these steps carefully reduces database load measurably without endangering the consistency of the application.

Doctrine Second Level Cache — the key facts at a glance

Activation

orm.second_level_cache.enabled: true plus a cache pool and at least one region definition in doctrine.yaml.

Cache attribute

Set #[ORM\Cache] at entity and association level, otherwise the cache stays ineffective despite activation.

Concurrency strategy

READ_ONLY for static data, NONSTRICT_READ_WRITE as a compromise, READ_WRITE for strict consistency.

Monitoring

Use CacheLogger and the Symfony Profiler to measure hit rate and catch misconfiguration early.

11. FAQ: Doctrine Second Level Cache

1First level vs. second level cache?
First level lives only in the EntityManager of one request. Second level stores persistently in Redis or APCu, across requests.
2Is doctrine.yaml configuration alone enough?
No, every entity also needs the #[ORM\Cache] attribute, otherwise the cache stays ineffective.
3Which concurrency strategy to choose?
READ_ONLY for static data, NONSTRICT_READ_WRITE as a compromise, READ_WRITE for strict consistency requirements.
4Does it work without Redis?
Technically with array cache or APCu, but in multi worker environments Redis is necessary for shared cache state.
5Difference to the query cache?
Query cache stores parsed SQL structure, not results. Second level cache stores entity data and avoids the query entirely.
6Manual invalidation after an SQL update?
With $cache->evictEntity() or $cache->evictEntityRegion() for an entire region.
7How to measure hit rate?
With Doctrine's CacheLogger and the Symfony Profiler when log_enabled is active.
8Can associations be cached?
Yes, via #[ORM\Cache] on the association, provided the target entity is also marked cacheable.
9Is there serialization overhead?
Yes, Doctrine serializes field values on every cache access. For very wide entities this can become noticeable.
10How to roll this out safely?
Mark a few noncritical entities first, watch hit rate over days, then expand gradually.