6 Minutes read

Logos of Symfony and Redis

A cache can improve response times by an order of magnitude. It can also become the reason why your users see outdated prices, inconsistent dashboards, or data that seems impossible to reproduce.

The difficult part about caching is not adding it. Symfony and Redis make that surprisingly easy. The difficult part is keeping cached data consistent over time.

A forgotten invalidation rule, an overly optimistic TTL, or a poorly structured cache can quietly turn a performance optimization into long-term technical debt.

Following the talk “I tested it for you: Symfony Cache component with Redis” at AFUP Day 2026 about Symfony Cache and Redis, I wanted to revisit the fundamentals: how Symfony approaches caching, what Redis brings to the table, and how to build caching strategies that remain predictable in production.

Why Redis is well-suited for caching

At first glance, Redis appears to be “just” a fast in-memory key-value store. In practice, what makes it highly effective for application caching — especially when paired with Symfony’s Cache component — is the combination of its data model, execution guarantees, and predictable performance characteristics.

In Memory

One of Redis’s fundamental strengths lies in its in-memory design, all data is kept in RAM, which allows most operations to complete in microseconds. This predictable latency is crucial for caching: the cache layer must always be faster than the system it is trying to offload, otherwise it becomes counterproductive.

Expiration

Redis also handles expiration natively. Each key can have an associated TTL, handled directly by the engine. This means expiration is both efficient and reliable, without requiring additional cleanup logic on the application side. When using Symfony Cache, this integrates naturally with methods like expiresAfter(), creating a seamless link between application logic and storage behavior.

Atomic

Redis also provides atomic operations by design. Every command is executed sequentially in a single-threaded loop, ensuring consistency without requiring locks in most cases. For caching, this is particularly valuable in scenarios like cache population under load, where multiple requests may attempt to compute the same value simultaneously. When combined with Symfony’s callback-based get() method, this significantly reduces race conditions and duplicate work.

Advanced data structures

Beyond simple key-value storage, Redis offers a range of data structures — sets, hashes, sorted sets — which Symfony leverages internally, especially for advanced features like tag-based invalidation. When you use the redis_tag_aware adapter, Symfony does not simply store your cache entries, it also maintains efficient mappings between tags and keys using Redis primitives. This allows for targeted invalidation without scanning the entire keyspace, which would be prohibitively expensive at scale.

Predictable eviction policies

Memory is finite, which raises an important question: what happens when Redis runs out of space? Since memory is finite, Redis allows you to define how keys are removed when limits are reached (LRU, LFU, etc.). This makes cache behavior more transparent and tunable.

Different strategies can be applied depending on the use case: Least Recently Used (LRU) favors keeping recently accessed data, while Least Frequently Used (LFU) prioritizes frequently accessed entries. Choosing the right policy can have a direct impact on your cache hit rate and overall application performance.

For a detailed overview of available policies and how they behave, you can refer to the official Redis documentation:
https://redis.io/docs/latest/develop/reference/eviction/

In a Symfony application, this predictability helps align cache usage with business priorities — ensuring, for example, that frequently accessed data is retained longer.

Back to the Basics: PSR‑6 and PSR‑16

Caching in PHP is structured around two standards that reflect two distinct approaches.

PSR‑6 provides a comprehensive model built around CacheItem objects. Each cached entry carries its own context: whether it is a hit or miss, how long it lives, and even the ability to defer writes. This approach is useful when fine-grained control over the cache lifecycle is required.

PSR‑16, by contrast, favors simplicity. Its interface exposes only basic operations — get, set, delete — with minimal overhead. It works well for straightforward use cases, but can become limiting as complexity grows.

Symfony builds on these standards while offering a more developer-friendly abstraction through Contracts.

Symfony Contracts: A Practical API

In day-to-day development, Symfony Contracts are what truly change how you interact with caching.

The CacheInterface introduces a simple yet powerful pattern: the get() method takes a key and a callback. If the value is already cached, it is returned immediately. Otherwise, the callback is executed, and its result is stored automatically.


return $cache->get('report:' . $id, function (ItemInterface $item): Report use ($id) {
$item->expiresAfter(3600);
return $this->computeReport($id);
});

This approach removes a significant amount of boilerplate code that was traditionally associated with caching. It also reduces the risk of inconsistencies, such as forgetting to store a computed value.

Beyond cache population, invalidation is where things become truly interesting. Via TagAwareCacheInterface, it allows you to associate multiple cache entries with logical labels and invalidate them in a single operation. This is essential for maintaining consistency without resorting to brute-force cache clears.

Structuring your cache: The Domain approach

A common anti-pattern is to treat the cache as a single shared bucket. It works at first, but as the application grows, cache keys become harder to reason about, invalidation becomes riskier, and ownership between teams becomes unclear.

On one project, user sessions were stored in the same Redis cache pool as application data. Everything looked fine until memory pressure increased and Redis started evicting keys according to its LRU policy. Since sessions and cache entries competed for the same memory space, active user sessions were regularly evicted long before their intended lifetime.

From a business perspective, sessions were supposed to remain valid for 30 days. In practice, some users were being logged out after only a few hours.

The issue was not caused by Redis itself, but by a lack of separation between different types of data with different requirements. Sessions, application cache entries, and other transient data often deserve dedicated storage areas, configurations, or even separate Redis instances depending on the scale of the system.

Redis is frequently used for several purposes simultaneously: application caching, session storage, rate limiting, queues, or locking mechanisms. While this consolidation simplifies infrastructure, it can also blur the boundaries between data with very different persistence and availability requirements.

Organizing the cache around business domains helps avoid these issues from the start.

A domain represents a functional boundary: product catalog, pricing, reporting, and so on. In Symfony, this translates into separate cache pools, each with its own configuration and rules.

This structure brings immediate benefits. It prevents key collisions, simplifies debugging, and enables targeted invalidation. In production, this clarity makes a noticeable difference.

Here is a typical configuration:


framework:
cache:
prefix_seed: 'myapp:prod'

pools:
catalog_cache:
adapter: cache.adapter.redis_tag_aware
default_lifetime: 900

reporting_cache:
adapter: cache.adapter.redis
default_lifetime: 3600

With this setup, cache keys are naturally namespaced and easier to reason about.

Easier invalidation through cache pools

Adding cache is easy. Maintaining consistency over time is much harder. This is where tag-based invalidation becomes critical.

Consider a common product catalog example:


return $this->cache->get('product:'.$id, function (ItemInterface $item): Product use ($id) {
$item->expiresAfter(600);
$item->tag(['product:'.$id, 'catalog']);
return $this->fetchProduct($id);
});

When a product is updated, invalidating the relevant entries becomes straightforward:

$this->cache->invalidateTags(['product:' . $id]);

This avoids heavy-handed strategies such as clearing the entire cache, which can severely impact performance and create load spikes.

And if you have a catalog page where all products are displayed, you also want to invalidate the cache of this very page:

$this->cache->invalidateTags(['product:' . $id, 'catalog']);

Choosing the right TTL

TTL should never be arbitrary. It reflects a trade-off between data freshness and performance.

Highly dynamic data, such as pricing or stock levels, requires short lifetimes. More stable data can be cached longer without risk.

Importantly, TTL acts as a safety net. It ensures that stale data eventually expires, even if invalidation logic fails. However, it should not replace a proper invalidation strategy.

Redis: A Pragmatic Backend Choice

When it comes to application caching, Redis is a natural fit. Its extremely low latency, native TTL support, and atomic operations make it well suited for this use case.

In PHP, two main clients are available. The phpredis extension, implemented in C, offers better performance and is generally recommended in production. Predis, written in pure PHP, remains useful in constrained environments or during development.

Symfony makes Redis integration straightforward:


framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(REDIS_URL)%'

Serialization: An Invisible Cost

Since Redis stores data as strings, PHP objects must be serialized before being cached.

In practice, this encourages the use of simple structures like arrays or DTOs. Complex objects, especially Doctrine entities, often introduce subtle issues due to lazy-loading proxies and relationships.

Symfony provides performance optimizations through marshallers such as igbinary, which can significantly improve serialization efficiency for large payloads.

Observability and Feedback Loops

An effective cache is one that is observed and continuously adjusted.

Symfony’s profiler provides useful insights in development, including hit/miss ratios and execution times. On the Redis side, metrics such as memory usage, eviction rates, and hit ratios are essential for understanding system behavior.

These measurements should guide decisions about TTLs, key design, and invalidation strategies.

Cache hit rate is often the first metric people look at, but it should never be analyzed in isolation. A high hit ratio can hide stale-data issues, while a lower hit ratio may be perfectly acceptable if consistency requirements are strict.

Conclusion

Application caching is not a secondary optimization. It is an architectural concern that needs to be designed, structured, and monitored. Caching should be considered from the start of a project as a foundational architectural concern, not as a last-minute performance fix applied after bottlenecks have already emerged.

Caching is most effective when it is treated as part of a broader performance strategy rather than as a standalone optimization.

Application caches, HTTP caches, reverse proxies (like Varnish), CDNs, and browser caches all solve different problems and complement each other. The strongest architectures usually combine several layers, each responsible for reducing a specific type of load.

Symfony and Redis provide excellent building blocks for this approach. The challenge is not storing data faster, but deciding how that data should expire, be invalidated, and remain consistent over time.

In the end, a cache is only as reliable as the strategy behind it.


Application Caching with Symfony and Redis was originally published in ekino-france on Medium, where people are continuing the conversation by highlighting and responding to this story.