Sitemap

Pragmatic application caching with Symfony Cache: Pools, Tags and Invalidation

--

Caching is one of those topics every developer thinks they understand until they hit production.

Adding cache is easy.
Keeping data consistent, predictable, and debuggable over time is not.

In many real-world applications, performance issues don’t come from obviously bad code. They come from reasonable design choices pushed to their limits: complex database queries, aggregated data, and endpoints that are called far more often than anticipated.

This article focuses on application-level caching: not HTTP cache, not database cache, but the layer where expensive computations and data aggregation happen.

Instead of presenting isolated examples, we will follow the evolution of a small but realistic project. Starting from a perfectly valid implementation, we will progressively introduce caching, and then improve it step by step as new problems appear.

Along the way, we will:

  • add cache in the most naive way
  • identify why that approach breaks down in production
  • introduce better cache design with dedicated pools
  • implement fine-grained invalidation using cache tags
  • connect cache invalidation to domain changes

All examples are based on a Symfony project and use the Symfony Cache component, but the problems, and the reasoning behind the solutions, apply far beyond a single framework.

The goal is not to show how to add cache, but how to design cache you can actually trust in production.

The following examples are not intended to be exhaustive, but to make each step of the cache evolution easy to understand.

Application cache in 2026: why it’s still a critical concern

Let’s start with a perfectly reasonable feature.

A Symfony application exposes an endpoint returning aggregated product statistics. The data is not particularly complex, but computing it requires joining several tables and grouping results.

final class ProductController
{
public function __construct(
private ProductStatsProvider $statsProvider,
) {}

public function __invoke(): JsonResponse
{
return new JsonResponse(
$this->statsProvider->getStats()
);
}
}

The heavy lifting happens in an application service:

final class ProductStatsProvider
{
public function __construct(
private EntityManagerInterface $em,
) {}

public function getStats(): array
{
return $this->em->createQuery(
'
SELECT c.name, COUNT(p.id) AS productCount
FROM App\Entity\Product p
JOIN p.category c
GROUP BY c.id
'
)->getResult();
}
}

Nothing here looks suspicious. The query is readable, the service is small, and response times are acceptable in development.

Howeveeeeer, the problem appears later in production.

When “reasonable” code becomes a bottleneck

Under real traffic, this endpoint quickly becomes a hot spot:

  • the same request is executed repeatedly
  • the same aggregation query is run for every call
  • database load grows linearly with traffic

At this point, teams often try to optimize around the problem:

  • adding indexes
  • tweaking the query
  • scaling database resources
  • increasing the number of PHP workers

All of these can help, but they miss the core issue:

The same expensive computation is executed again and again for identical inputs.

This is where application-level caching becomes unavoidable.

First attempt: adding cache

The most natural improvement is to cache the result of the query.

use Symfony\Contracts\Cache\CacheInterface;

final class ProductStatsProvider
{
public function __construct(
private EntityManagerInterface $em,
private CacheInterface $cache,
) {}

public function getStats(): array
{
return $this->cache->get('product_stats', function () {
return $this->em->createQuery(
'
SELECT c.name, COUNT(p.id) AS productCount
FROM App\Entity\Product p
JOIN p.category c
GROUP BY c.id
'
)->getResult();
});
}
}

At first glance, this change is extremely effective:

  • the query runs once
  • subsequent requests are fast
  • database load drops immediately

This is usually the moment when everyone feels relieved.

Why this is not enough

This implementation works until it doesn’t.

Several problems are already hiding here:

  • the cache key is global and poorly scoped
  • there is no explicit TTL
  • there is no invalidation strategy
  • the cache pool is implicit
  • consistency is purely accidental

These issues don’t show up in development. They show up weeks later, when:

  • data changes
  • bugs are reported
  • cached values become stale
  • no one remembers where the cache was added

At this stage, the cache becomes a liability instead of an asset.

Caching data is easy.
Designing cache you can trust in production is not.

In the next sections, we will progressively fix these issues, not by adding complexity, but by making cache an explicit, well-designed part of the application.

Designing cache pools: one size does not fit all

In the previous section, we added cache in the simplest possible way. It worked but only by accident.

One important detail was left implicit: which cache are we actually using?

By default, the CacheInterface injected in a Symfony service points to the application cache pool. This is convenient, but it hides a critical architectural decision.

Using a single cache pool for everything quickly becomes a problem:

  • different data have different lifecycles
  • not all cached values have the same volatility
  • not all cache backends behave the same under load

Treating cache as a single global bucket is an easy way to lose control.

Making the cache explicit

The first step toward a production-grade design is to make cache pools explicit and intentional.

Let’s start by defining a dedicated cache pool for our aggregated product statistics.

# config/packages/cache.yaml
framework:
cache:
pools:
product.statistics:
adapter: cache.adapter.redis

This pool is now:

  • isolated from the rest of the application cache
  • backed by Redis (fast, shared, production-friendly)
  • explicitly tied to a specific use case

Already, this changes how we reason about cache.

Injecting a dedicated pool

Instead of injecting the default cache, we inject our custom pool:

use Symfony\Contracts\Cache\CacheInterface;

final class ProductStatsProvider
{
public function __construct(
private EntityManagerInterface $em,
private CacheInterface $productStatisticsCache,
) {}

public function getStats(): array
{
return $this->productStatisticsCache->get('product_stats', function () {
return $this->em->createQuery(
'
SELECT c.name, COUNT(p.id) AS productCount
FROM App\Entity\Product p
JOIN p.category c
GROUP BY c.id
'
)->getResult();
});
}
}

💡 Pro tips

An autowiring alias is created for each pool using the camel case version of its name!

Nothing else changed and that’s the point.

The behavior is the same, but the intent is now explicit:

  • this cache exists for one purpose
  • its backend can evolve independently
  • its configuration is visible and reviewable

Designing pools around use cases

Once you adopt this mindset, defining multiple cache pools becomes natural.

A typical application might use:

  • a Redis-backed pool for hot, frequently accessed data
  • a filesystem-backed pool for slower, less volatile data
  • a dedicated pool for HTTP-related caching concerns

Each pool can:

  • have its own adapter
  • define its own TTL strategy
  • support or not support features like tagging

Cache pools should reflect use cases, not convenience.

This is a small change, but it sets the foundation for everything that follows, especially cache invalidation.

In the next section, we will refine this further by making cache entries more explicit and safer to use: stable keys, meaningful TTLs, and cache written as application code.

Writing cache-aware application services

At this point, we have a dedicated cache pool. That’s a good start but the cache usage itself is still fragile.

It works, but several things are still implicit:

  • the cache key has no clear structure
  • the lifetime of the data is undefined
  • the cache entry has no explicit meaning

This is where many cache-related bugs are born.

Stable and meaningful cache keys

Cache keys should be:

  • deterministic
  • stable over time
  • explicit about what they represent

Instead of a generic key like product_stats, let’s make the intent clear:

namespace App\Cache\Product;

enum Key: string
{
case ByCategory = 'by_category';
}

This enum:

  • centralizes cache keys
  • avoids typos
  • makes refactoring safer
  • gives semantic meaning to each cached value

Cache keys are no longer strings, they are explicit concepts.

Using the enum in the application service:

use App\Cache\Product\Key;
use Symfony\Contracts\Cache\CacheInterface;

final class ProductStatsProvider
{
public function __construct(
private EntityManagerInterface $em,
private CacheInterface $productStatisticsCache,
) {}

public function getStats(): array
{
return $this->productStatisticsCache->get(
Key::ByCategory->value,
function () {
return $this->em->createQuery(
'
SELECT c.name, COUNT(p.id) AS productCount
FROM App\Entity\Product p
JOIN p.category c
GROUP BY c.id
'
)->getResult();
}
);
}
}

Cache keys are part of your public contract with production, they deserve to be treated as such.

Adding an explicit TTL

Relying on the default cache lifetime is rarely a good idea.
Even when invalidation is implemented later, a TTL acts as a safety net.

In Symfony cache pools allow you to define expiration at the configuration level, where it belongs.

# config/packages/cache.yaml
framework:
cache:
pools:
product.statistics:
adapter: cache.adapter.redis
default_lifetime: 3600

The TTL depends on your business logic, so it is important to choose one that suits your needs. In this example, it is not very important that product statistics are not updated for an hour.

Cache at the right architectural level

Notice what we did not change:

  • the controller stays thin
  • the repository stays unaware of cache
  • the cache lives in an application service

This is intentional.

Cache is part of the read side of your application.
It should be close to the use case, but decoupled from infrastructure details.

At this point, the cache implementation is clean, explicit, and configurable. And yet, I know, it is still incomplete.

The remaining flaw

Even with:

  • a dedicated pool
  • stable keys
  • explicit TTL

The cache is still time-driven.

If a product or a category changes, the cached value remains stale until the TTL expires. In production, this is usually unacceptable. TTL is about expiration, it is not an invalidation strategy.

In the next section, we’ll tackle this problem head-on and explain why TTL alone is not enough and why most cache issues originate there.

Semantic invalidation with cache tags

Up to now, our cache entries were identified by a single key. That works well for reads, but it completely falls apart when data changes.

The problem is not how we cache data.It’s how we invalidate it.

From keys to meaning

Let’s go back to our cached product statistics. The cached value represents products grouped by categories. So the cache is logically tied to all products and all categories.

If any of those change, the cache becomes invalid.

Trying to express this relationship with cache keys quickly becomes unmanageable. You end up either clearing everything or trying to recompute keys manually.

This is exactly what cache tags are designed for.

Enabling tag-aware cache

First, the cache pool must support tags.

# config/packages/cache.yaml
framework:
cache:
pools:
product.statistics:
adapter: cache.adapter.redis_tag_aware
default_lifetime: 3600

By switching to a tag-aware adapter, we unlock a crucial feature: semantic invalidation.

Tagging cache entries

Let’s update our application service to add tags to the cached item.

Instead of scattering raw strings across the codebase, we can centralize cache tags in a dedicated enum.

namespace App\Cache\Product;

enum Tag: string
{
case Product = 'product';
case Category = 'category';
}

Using it in our service:

use App\Cache\Product\Key;
use App\Cache\Product\Tag;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;

final class ProductStatsProvider
{
public function __construct(
private EntityManagerInterface $em,
private CacheInterface $productStatisticsCache,
) {}

public function getStats(): array
{
return $this->productStatisticsCache->get(
Key::ByCategory->value,
function (ItemInterface $item) {
$item->tag([
Tag::Product->value,
Tag::Category->value,
]);

return $this->em->createQuery(
'
SELECT c.name, COUNT(p.id) AS productCount
FROM App\Entity\Product p
JOIN p.category c
GROUP BY c.id
'
)->getResult();
}
);
}
}

The cache entry is now explicitly linked to two domain concepts: products and categories.

This single change fundamentally alters how we can reason about cache invalidation.

Invalidating by tag

Instead of clearing the entire cache, we can now invalidate only what is impacted.

    // ...

public function __construct(
private TagAwareCacheInterface $productStatisticsCache
) {}

public function dummy(): void
{
$this->productStatisticsCache->invalidateTags([
Tag::Product->value,
Tag::Category->value,
]);

// ...
}

Because $productStatisticsCache implements TagAwareCacheInterface (thanks to cache.adapter.redis_tag_aware), we can invalidate the cached items by calling invalidateTags().

At this point, we have solved the how of invalidation. One important question remains: when should invalidation happen?

That’s where the write side of the application comes into play!

Triggering cache invalidation from the write side

Up to now, cache invalidation was explicit and manual.
We can invalidate tags, but someone has to decide when to do it.

And this is where most Symfony projects go wrong:

  • cache invalidation is scattered
  • sometimes forgotten
  • often duplicated
  • tightly coupled to controllers or application services

A good rule of thumb:

The code that mutates the model should be responsible for invalidating the cache, not the code that reads it.

Let’s see two common approaches in a Symfony + Doctrine project.

Doctrine listeners

Doctrine listeners or subscribers are often the first solution that comes to mind.

use App\Cache\Product\Tag;
use App\Entity\Product;
use Doctrine\ORM\Event\PostUpdateEventArgs;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Events;

#[AsDoctrineListener(event: Events::postUpdate)]
final class ProductStatisticsCacheListener
{
public function __construct(
private TagAwareCacheInterface $productStatisticsCache,
) {}

public function postUpdate(PostUpdateEventArgs $args): void
{
$entity = $args->getObject();

if (!(
$entity instanceof Product
|| $entity instanceof Category
)) {
return;
}

$this->productStatisticsCache->invalidateTags(
Tag::Product->value,
Tag::Category->value,
);
}
}

This works. It’s automatic. And it looks clean! But there are trade-offs.

The hidden costs:

  • Doctrine events fire very low in the stack
  • They lack business intent (why did this change happen?)

Cache invalidation becomes implicit and sometimes surprising.

Doctrine listeners are great for technical concerns.
Cache invalidation is usually a business concern.

Domain events

Instead of reacting to persistence, we react to meaningful domain changes.

namespace App\Domain\Product\Event;

final class ProductUpdated
{
public function __construct(
public readonly int $productId,
public readonly int $categoryId,
) {}
}

This event is explicit! It says what happened, not how.

To dispatch the event, we must add inside an application service or command handler:

use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class UpdateProductHandler
{
public function __construct(
private EventDispatcherInterface $dispatcher,
) {}

public function __invoke(Product $product): void
{
// mutate product, persist & flush, etc.

$this->dispatcher->dispatch(
new ProductUpdated(
$product->getId(),
$product->getCategory()->getId(),
)
);
}
}

Cache invalidation is now decoupled from persistence.

Handling the event and invalidating cache:

use App\Cache\Product\Tag;
use App\Domain\Product\Event\ProductUpdated;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener]
final class ProductStatisticsCacheSubscriber
{
public function __construct(
private TagAwareCacheInterface $productStatisticsCache,
) {}

public function __invoke(ProductUpdated $event): void
{
// ...

$this->productStatisticsCache->invalidateTags([
Tag::Product->value,
Tag::Category->value,
]);
}
}

This gives us:

  • explicit intent
  • fine-grained invalidation
  • predictable behavior
  • easy testing

And most importantly: cache becomes a reaction to business events, not a side effect of persistence.

Handling contextual variations: Namespaced caches (Symfony 7.3+)

Up to now, we’ve cached data tied to our domain model (products, categories). But what about data that varies by context?

Consider these scenarios:

  • User-specific dashboards (same computation, different user)
  • Locale-dependent product names or prices
  • Feature flag variations (checkout v1 vs checkout v2 A/B test)
  • Multi-tenant statistics (same aggregation, different tenant)

The old way: key concatenation hell

$userStatsKey = sprintf('stats.user.%d.category.%s', $userId, $categoryId);
$localeStatsKey = sprintf('stats.%s.category.%s', $request->getLocale(), $categoryId);
$featureStatsKey = sprintf('stats.%s.%s', $featureFlag, $categoryId);

Namespaced caches: context as first-class concept

Symfony 7.3 introduced NamespacedPoolInterface, letting you create sub-namespaces from any CacheInterface. This is so cool! No more key manipulation:

use App\Cache\Product\Key;
use App\Cache\Product\Tag;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\NamespacedPoolInterface;

final class ContextualProductStatsProvider
{
public function __construct(
private CacheInterface $productStatisticsCache,
) {}

public function getUserStats(int $userId): array
{
$userCache = $this->productStatisticsCache->withSubNamespace("user-{$userId}");

return $userCache->get(
Key::ByCategory->value,
function (ItemInterface $item) {
$item->tag([
Tag::Product->value,
Tag::Category->value,
]);
return $this->computeStats();
}
);
}
}

Physical storage (Redis example):

user-123::by_category     tags: [product,category]
user-456::by_category tags: [product,category]
user-123::by_color tags: [color,category] // Different computation

The key user-123::by_color correspond to a different computation and is not tagged with product.

Tags + Namespaces = multidimensional invalidation

They work orthogonally on the same physical keys:

// Product changes -> invalidates ACROSS all users/contexts
$productStatisticsCache->invalidateTags([Tag::Product->value]);

// Result for the next request:
user-123::by_category ->MISS (had "product" tag)
user-456::by_category ->MISS (had "product" tag)
user-123::by_color ->HIT (tags: color,category -> no "product")

Versioning namespaces: invalidating entire contexts

No invalidateNamespace() API. Instead, evolve the namespace value:

class UserDashboardProvider
{
public function getDashboard(int $userId): array
{
$user = $this->userRepository->find($userId);
$version = $user->getPreferencesVersion();

$userCache = $this->userStatisticsCache
->withSubNamespace("user-{$userId}-prefs-v{$version}");

return $userCache->get('dashboard', fn($item) => [
return $this->computeDashboard($user);
]);
}
}

Flow:

  1. user123 visits -> stores user-123-prefs-v1::dashboard
  2. user123 changes preferences ->prefs_version: 1->2
  3. next visit ->user-42-prefs-v2::dashboard (v1 auto-misses)

Namespaces vs tags: choosing the right tool

Namespaces and tags solve different problems:

  • Namespaces are hierarchical and context-driven -> “all cache entries for this user / locale / tenant”
  • Tags are semantic and transversal -> “everything related to products or categories”

In practice, they work best together:

  • namespaces to scope cache by context
  • tags to invalidate cache by domain events

Symfony 7.3+ makes this combination explicit and safe, giving you multiple, complementary strategies to keep cache correct, predictable, and production-ready.

Conclusion

Caching is not about making code faster, it’s about making performance predictable.

In a Symfony project, the Cache component gives you all the tools you need:

  • dedicated pools
  • safe cache access via CacheInterface
  • semantic invalidation with tags
  • tight integration with your domain model

The real challenge is not caching data, but invalidating it correctly.
Once cache invalidation becomes a first-class concern, driven by business events and expressed in domain terms, caching stops being fragile and starts being reliable.

Used this way, cache is no longer a hack or an afterthought.
It becomes a deliberate, observable part of your architecture.

And that’s usually the difference between a cache that exists… and a cache you can trust in production!

Thank you for taking the time to read this article, I hope you enjoyed it and learned something! If so, please feel free to put a clap 👏 on it and any feedback is welcome!

--

--

Nicolas Jourdan
Nicolas Jourdan

Written by Nicolas Jourdan

Symfony Certified 🎓 Lead Tech at Smart Traffik 🚀