Two cache layers for two different problems, and how to use both correctly
Doctrine ships two fundamentally different caching mechanisms that at first glance both aim for the same goal, less load on the database, but work in structurally different ways. The Query Result Cache remembers the finished result of a specific query, while the Second-Level Cache keeps individual entities available regardless of the query that originally loaded them. Mixing the two up leads either to a caching layer that serves stale data after every write, or to one that's needlessly heavy for the actual problem at hand. This article clarifies the differences with concrete examples and shows how to handle cache invalidation properly.
Table of Contents
- 1. Two cache layers, two different problems
- 2. Query Result Cache: how it works
- 3. Second-Level Cache: entity caching across query boundaries
- 4. When the Result Cache is enough
- 5. When Second-Level Cache is needed
- 6. Cache invalidation on write operations
- 7. Redis and APCu as a cache backend compared
- 8. Configuration in doctrine.yaml
- 9. Before/after performance measurement
- 10. Summary
- 11. FAQ
1. Two cache layers, two different problems
The Query Result Cache operates at the level of a specific, executed query: it stores the serialized result of a given DQL or SQL query together with its parameters under a cache key derived from the query itself. If the same code later runs the same query with the same parameters again, Doctrine returns the cached result without even opening a database connection. The problem this cache solves is purely about performance: an expensive, frequently run query shouldn't hit the database again on every call.
The Second-Level Cache operates one layer deeper, right at the entities themselves, regardless of which query originally loaded them. Once an entity gets loaded through any query, it lands in the Second-Level Cache and can afterward be reused via find() or as part of an association on another object, without being loaded again, even if the access path is entirely different from the first load. This cache solves a different problem: it cuts down on redundant loading of the same entity across many different code paths, not just repeated execution of the same query.
2. Query Result Cache: how it works
The Query Result Cache is enabled explicitly per query, either via ->setResultCacheLifetime(3600) on the Query object or as a DQL hint. Without that explicit opt-in, not a single query gets cached automatically, which is a deliberate design decision: Doctrine doesn't want to guess which queries are worth caching, and leaves that call to the developer who actually understands the business context. For queries whose results change frequently, say a stock display, caching is usually counterproductive, while for rarely changing reference data like a list of countries, a long lifetime of several hours is perfectly reasonable.
The example below shows a typical repository method loading a list of active product categories, with a one-hour cache lifetime and an explicit cache key that ensures the same cache entry keeps getting found even after minor changes to the query structure.
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
final class CategoryRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Category::class);
}
/**
* @return list<Category>
*/
public function findActiveCategories(): array
{
return $this->createQueryBuilder('c')
->andWhere('c.active = true')
->orderBy('c.position', 'ASC')
->getQuery()
->enableResultCache(3600, 'active_categories')
->getResult();
}
}
3. Second-Level Cache: entity caching across query boundaries
The Second-Level Cache is enabled per entity class, either through the #[Cache] attribute right on the class or via XML/YAML mapping configuration, and can additionally be tuned per association for finer control. Once enabled, Doctrine automatically ensures that a loaded entity lands in the cache and, on every later access regardless of the code path, comes from the cache instead of the database, as long as it hasn't been explicitly invalidated.
The key difference from the Query Result Cache is reusability: a Category entity cached via the Second-Level Cache gets served from cache both on a direct find(5) and when loaded through a Product association, while a Query Result Cache entry only applies to the exact same query with the exact same parameters. That reusability makes the Second-Level Cache more powerful, but also more complex to invalidate correctly, since a single entity change can potentially affect many different cache entries created through different queries.
4. When the Result Cache is enough
The Query Result Cache is the right choice when a specific, clearly identifiable query runs frequently with the same or very similar parameters, say a homepage that always loads the same five featured products, or a dropdown of shipping countries that's identical on every form load. In these cases the query itself is the stable reference point, not the individual entities, and the Result Cache solves the problem with minimal configuration effort.
For reports or aggregate queries that return computed values like sums or averages instead of individual entities, the Result Cache is the only sensible option, since the Second-Level Cache works exclusively at the entity level and has no way to handle raw scalar values coming out of an aggregate query. For a dashboard showing revenue figures refreshed once a day, a Result Cache with a 24-hour lifetime is entirely sufficient.
5. When Second-Level Cache is needed
Second-Level Cache pays off once the same entities get loaded repeatedly through many different, unpredictable code paths, say a Country entity referenced during address validation, at checkout, in shipping cost calculation, and in the admin backend. Without Second-Level Cache, each of those spots would load the entity separately from the database, even if it had already been loaded elsewhere in the same request cycle, since Doctrine's identity map only applies within a single entity manager and request, not across requests.
Second-Level Cache is particularly valuable for reference data with a low change frequency but high read frequency, like countries, currencies, tax classes, or product categories. For constantly changing data like stock levels or prices that can update several times a minute, Second-Level Cache is risky instead, since invalidation logic grows complex and the odds of serving stale data from the cache rise with the change frequency.
6. Cache invalidation on write operations
Second-Level Cache invalidates itself largely automatically on changes made through the same entity manager: calling $entityManager->flush() after modifying a cached entity makes Doctrine update the corresponding cache entry along with it. Things get tricky only once data changes outside of Doctrine, say through a direct SQL update in a migration script or another process modifying the same data through a different connection. In those cases the cache entry has to be explicitly evicted via $cache->evictEntity(Category::class, $id) or cleared entirely per region.
The Query Result Cache, on the other hand, never invalidates itself automatically, because Doctrine has no way to know which tables an arbitrary DQL query actually touches without parsing and semantically understanding the query itself. After every relevant write operation the cache entry therefore has to be explicitly cleared using the same cache key it was stored under, which is why it's worth defining cache keys consistently in one central place, say as constants in the relevant repository, instead of repeating them as strings scattered across the codebase.
7. Redis and APCu as a cache backend compared
APCu stores cache data in the shared memory of the PHP process on the same server, making it extremely fast since no network round trip is needed. The decisive downside shows up with multiple application servers behind a load balancer: every server keeps its own, independent APCu cache, meaning an invalidation on server A never touches the cache on server B, leading to inconsistent state depending on which server happens to handle a given request.
Redis solves this by keeping the cache centrally on a separate server shared by every application server, so an invalidation takes effect everywhere immediately. The cost is a network round trip per cache access, which for a Redis instance in the same data center usually stays in the low single-digit millisecond range, but is never zero. In practice, Redis is close to the only sensible choice for Second-Level Cache once multiple application servers are involved, while APCu remains useful for the Result Cache on a single-server setup, or as a local L1 layer in front of a Redis-backed L2 cache.
8. Configuration in doctrine.yaml
Both cache layers are configured centrally under the orm key in config/packages/doctrine.yaml. For the Result Cache, a single query_cache_driver entry pointing at a configured cache pool is usually enough, while the Second-Level Cache additionally needs second_level_cache.enabled: true plus one or more regions, each of which can have its own lifetime settings and its own cache driver.
It's worth setting up a dedicated region with a long lifetime, say 'reference_data', for reference data that changes very rarely, and a second region with a shorter lifetime for entities that change more frequently but are still worth caching. That separation prevents a one-size-fits-all cache configuration from being either too aggressive for volatile data or too conservative for stable reference data.
9. Before/after performance measurement
Before introducing a caching layer, it's worth running a baseline measurement with the Symfony Profiler or a tool like Blackfire, which surfaces the number of executed queries and their cumulative duration for a typical request. Without that baseline, the actual benefit of any caching effort can't be objectively proven later, and there's a real risk of adding complexity for an effect that turns out to be negligible.
After introducing the cache, the same measurement should be repeated, ideally under realistic load with a tool like k6 or Apache Bench, to confirm the effect not just in the number of database queries but also in actual response time under concurrent requests. A commonly underestimated effect is that an overly aggressive Second-Level Cache configuration, spread across many distinct entities, can end up consuming more memory on the Redis server than it saves in database load, which is why the cache backend's own memory footprint should be part of the before/after measurement as well.
| Criterion | Query Result Cache | Second-Level Cache | Recommendation |
|---|---|---|---|
| Cache layer | Finished query result | Individual entity | Choose based on access pattern |
| Reusability | Only the exact same query | Across any access path | Second-Level for many code paths |
| Invalidation | Always manual | Automatic on flush() via ORM | Second-Level for ORM-only writes |
| Aggregate queries | Works without issue | Not applicable | Result Cache for reports |
| Recommended backend | APCu or Redis | Redis with multiple servers | Redis for multi-server setups |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
Doctrine Caching: Key Takeaways
Query Result Cache
Remembers finished query results, ideal for repeated, identical queries.
Second-Level Cache
Caches individual entities across any access path and query boundary.
Invalidation
Second-Level is automatic on ORM writes, Result Cache always needs manual clearing.
Backend choice
APCu for a single server, Redis is mandatory once multiple app servers are involved.