Reducing Database Load with Doctrine Second Level Cache
Stop redundant database queries by implementing Doctrine's Second Level Cache (SLC). Learn how to configure Redis as a provider and choose the right concurrency strategy for your data.
13 Aug 2025, 04:41 UTC

The Cost of Repeated Entity Lookups
In a typical Doctrine application, every request starts with a blank slate. Even if you fetch the same User or Setting entity a thousand times across a thousand different requests, Doctrine will issue a SELECT query every single time. While the database is fast, these redundant round-trips create unnecessary latency and CPU load, especially for static or slow-changing data.
The Second Level Cache (SLC) solves this by moving entity data into a shared external store—like Redis or Memcached—after the first load. Unlike the first-level cache (which only lasts for the duration of a single PHP request), the SLC persists across requests, allowing your application to bypass the database entirely for frequently accessed entities.
Choosing the Right Cache Concurrency Strategy
You cannot simply "turn on" the SLC; you must tell Doctrine how to handle data consistency. This is done via the usage attribute on the @Cache annotation (or PHP 8 attribute). Choosing the wrong strategy can lead to stale data or degraded performance.
- READ_ONLY: Best for immutable data (e.g., a list of countries). It is the fastest strategy because Doctrine assumes the data never changes and never needs to check for updates.
- NONSTRICT_READ_WRITE: Suitable for data that changes occasionally and where absolute consistency isn't critical. It is faster than READ_WRITE but carries a small risk of returning slightly stale data during a concurrent update.
- READ_WRITE: The safest option for data that changes frequently. It uses a locking mechanism to ensure that a process doesn't cache stale data while another process is updating the same entity.
Implementing SLC with Redis
To implement the SLC, you need a PSR-6 compatible cache provider. In this example, we assume a Symfony environment using Redis.
1. Configure the Cache Provider
In your doctrine.yaml, enable the second-level cache and define the provider. This tells Doctrine where to physically store the cached entity payloads.
# config/packages/doctrine.yaml
doctrine:
orm:
second_level_cache:
enabled: true
region_cache_driver:
type: service
id: doctrine.system_cache_provider # This should point to your Redis adapter
2. Annotate the Entity
Apply the cache attribute to the entity class. For a User entity that is read often but updated occasionally, NONSTRICT_READ_WRITE is a balanced choice.
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Cache
#[ORM\Entity]
#[ORM\Cache(usage: "NONSTRICT_READ_WRITE", region: "user_region")]
class User
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column(type: "integer")]
private int $id;
#[ORM\Column(type: "string")]
private string $username;
// Getters and setters...
}
3. Fetching the Entity
To trigger the SLC, you must use the repository's find() method. Standard DQL queries do not use the SLC by default unless explicitly told to do so via setCacheable(true).
// Run this in a controller or service
$user = $entityManager
->getRepository(User::class)
->find($userId);
Verifying the Cache Hit
Because the SLC happens silently behind the scenes, you need specific tools to verify it is working. If misconfigured, Doctrine will simply fall back to the database without throwing an error.
Method A: SQL Logging
Run the find() method twice in a single script or across two page refreshes. Check your SQL logs (or the Symfony Profiler). The first call should show a SELECT query; the second call should show no SQL query for that entity.
If using Redis, you can check for the presence of the cached entity directly from the terminal. Run this command on your Redis server:
# Run via terminal on the server hosting Redis
redis-cli KEYS "*User*"
You should see keys corresponding to the entity class and the specific ID of the user you fetched.
Trade-offs and Limitations
The SLC is not a "silver bullet" for performance. It introduces two primary challenges:
- Memory Overhead: Every cached entity consumes RAM in your Redis/Memcached instance. For tables with millions of rows, caching everything will lead to memory exhaustion. Only cache "hot" entities.
- Invalidation Complexity: If you update a record directly in the database via SQL (bypassing the Doctrine EntityManager), the SLC will not know. It will continue serving the old version of the entity until the cache expires or is manually cleared.
Actionable Summary
Use the Second Level Cache for entities that are read frequently but updated infrequently. Start with READ_ONLY for static data to minimize overhead, and move to NONSTRICT_READ_WRITE for user-profile style data. Always verify the implementation by monitoring your SQL query count to ensure the database is actually being bypassed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.