Symfony Cache Component: HTTP Caching, Application Cache, and the Patterns That Make Them Work Together
Ask a few Symfony developers how caching works in their app and the answers rarely match. One talks about Cache-Control headers, another about $cache->get() with a callback, and someone mentions Doctrine's result cache without being sure whether it uses Redis or the filesystem. The Symfony cache component is really three separate mechanisms that happen to share a word, and the confusion starts when people expect one of them to do the job of another.
This post lays out what each layer does, where it sits in the request lifecycle, and how to wire all three so they cooperate. The running example is a multi-tenant SaaS dashboard: every user sees personalized data, but most of that data only changes a few times per hour. That is exactly the case where one cache layer is never enough.
Three caches inside the Symfony cache component
The simplest way to keep them apart is to ask where in the request each one intervenes.
HTTP caching happens before your application code runs. A reverse proxy (Symfony's own HttpCache kernel, Varnish, or Nginx) looks at the incoming request, finds a stored response that matches, checks its freshness headers, and returns it. Your controller never executes. PHP-FPM never picks up the request. This is the only layer that can drop your server load to near zero for a given URL.
The application cache happens inside your code. You wrap an expensive operation (a slow query, an external API call, a computed aggregate) in a cache call and let the component decide whether to run it or return the stored value. The controller runs, but the expensive part inside it may not.
The Doctrine result cache sits one level lower. It stores the hydrated rows of a specific DQL query so that the same query with the same parameters skips the database. Your controller runs, your repository method runs, and only the round trip to PostgreSQL or MySQL is skipped.
Each layer is cheaper the closer it is to the edge, and harder to personalize the closer it is to the edge. Deciding which data belongs at which layer is most of the design work when using the Symfony cache component well.
HTTP caching with HttpCache and cache headers
Symfony's HttpCache is a reverse proxy written in PHP, enabled with framework.http_cache: true. It is slower than Varnish because it still runs in PHP, but it needs no extra infrastructure and behaves like a real proxy, which makes it a good way to validate your headers before you put Varnish in front.
The headers are what actually matter. A response becomes cacheable by a shared cache when it says so:
$response->setPublic();
$response->setSharedMaxAge(300);
$response->headers->addCacheControlDirective('must-revalidate');
s-maxage targets shared caches (proxies), while max-age targets the browser. For a dashboard you almost always want the proxy to cache and the browser to revalidate, so s-maxage is the one you set high.
Now the obvious objection: dashboard responses are personalized, and a proxy cannot serve user A's page to user B. This is correct, and it is where most teams give up on HTTP caching for logged-in areas. Two techniques get you most of the way back.
The first is Edge Side Includes. The page shell stays uncached (or cached per user via Vary: Cookie, which is usually a bad idea), and the expensive fragments are rendered as separate ESI requests with their own cache lifetimes. A tenant-wide "open invoices" widget can carry s-maxage=600 and be shared by every user in that tenant, while the header showing the user's name is not cached at all. Symfony supports ESI natively through framework.esi: true and the render_esi() Twig function.
The second is splitting personalized data out of the HTML entirely. The page becomes a static shell served from the proxy, and the personalized numbers come from a JSON endpoint that uses the application cache described below. More work up front, but it pays off as traffic grows.
For invalidation, plain TTLs are rarely enough. When a tenant admin edits an invoice, the "open invoices" fragment should update now, not in ten minutes. Varnish supports tagged purging through its xkey module, and the FOSHttpCacheBundle exposes this as a Cache-Tags-style header on responses plus an invalidation service you call from your domain code. Symfony's built-in HttpCache does not support tag-based purging, so if targeted invalidation is a requirement, plan on Varnish from the start.
The Cache contracts and tag-based invalidation
The application cache is where most day-to-day caching lives. The modern API is Symfony\Contracts\Cache\CacheInterface, and the core of it is a single method:
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
public function __construct(private CacheInterface $cache) {}
public function openInvoiceTotal(int $tenantId): Money
{
return $this->cache->get(
sprintf('tenant.%d.open_invoice_total', $tenantId),
function (ItemInterface $item) use ($tenantId): Money {
$item->expiresAfter(600);
return $this->invoices->sumOpenForTenant($tenantId);
}
);
}
The callback runs only on a miss. The older PSR-6 pattern of getItem(), isHit(), set(), save() is more verbose and does nothing to prevent stampedes. The contracts API handles that for you, and we will come back to it.
Tags are the feature that turns this from "a key-value store" into something you can reason about. Inject TagAwareCacheInterface instead, tag items inside the callback, and invalidate by tag when the underlying data changes:
$item->tag(['tenant.' . $tenantId, 'invoices']);
// later, in the invoice update handler
$this->cache->invalidateTags(['tenant.' . $tenantId]);
This is the pattern that makes a multi-tenant dashboard workable. Every cached value belongs to a tenant tag. When anything changes for that tenant, you flush the tenant's tag and nothing else. Other tenants keep their warm cache. Without tags you end up either flushing everything, which causes a stampede, or maintaining a hand-written list of keys that drifts out of date within a week.
Tag support requires an adapter that implements it. RedisTagAwareAdapter is the right choice for production; TagAwareAdapter wrapping a plain adapter works but stores tag metadata as extra items and is measurably slower.
Stampede protection built into the contracts
A cache stampede happens when a popular item expires and fifty concurrent requests all miss at once, all run the expensive callback, and all write the same result back. For a per-tenant aggregate that takes two seconds to compute, fifty parallel executions can take down the database for everyone.
The contracts API ships two defenses. The first is locking: when one process is computing a value, other processes for the same key wait for the result instead of computing it again. The second is probabilistic early expiration. Each item records how long its callback took to compute, and as the TTL approaches, a request has a rising chance of recomputing the value early, in the background of a single request, while everyone else keeps reading the still-valid stored value. The $beta argument to get() tunes this; the default of 1.0 is fine for almost everyone, and INF forces an immediate recompute (useful in a warm-up command).
This alone is the reason to use CacheInterface::get() with a callback instead of the PSR-6 API. Hand-rolling "check, compute, store" on top of Redis in your own code throws that protection away and is nearly always a regression.
Doctrine result cache and how it overlaps with the application cache
Doctrine can cache the result of a specific query:
$query = $this->createQueryBuilder('i')
->where('i.tenant = :tenant')->andWhere('i.status = :status')
->setParameters(['tenant' => $tenantId, 'status' => 'open'])
->getQuery();
$query->enableResultCache(300, 'tenant_' . $tenantId . '_open_invoices');
Point it at a Symfony cache pool rather than its own driver so it shares the Redis connection:
doctrine:
orm:
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
framework:
cache:
pools:
doctrine.result_cache_pool:
adapter: cache.app
The question people rarely ask is whether they need this at all once they have the application cache. In most cases the answer is no. If a service method already wraps the query in $cache->get(), caching the same query a second time inside Doctrine buys nothing and creates two TTLs that can disagree. Doctrine's result cache is also not tag-aware; you cannot invalidate tenant_42_open_invoices by tag, only by key or by waiting.
Where the result cache earns its place is for small, shared reference data that many different code paths query independently: currency tables, plan definitions, feature flags. Those queries have no tenant dimension, change rarely, and are called from dozens of places where wrapping each one in a cache call would be noise.
A useful rule: the application cache holds domain results that have owners and invalidation triggers; the Doctrine result cache holds reference data with a short TTL and no invalidation logic at all.
Redis configuration that holds up in production
Everything above assumes a shared store, and for a multi-server setup that means Redis. The baseline configuration:
framework:
cache:
app: cache.adapter.redis_tag_aware
default_redis_provider: '%env(REDIS_URL)%'
default_marshaller: cache.default_marshaller
pools:
cache.dashboard:
adapter: cache.app
default_lifetime: 600
cache.reference_data:
adapter: cache.app
default_lifetime: 3600
A few decisions here deserve explanation.
Separate pools per concern let you clear one area without touching the others. bin/console cache:pool:clear cache.dashboard after a deployment that changes the dashboard's data shape leaves reference data warm. Pools also get their own namespace prefix, so a key collision between two features is impossible.
The marshaller decides how PHP values are turned into bytes. The default uses serialize(). If the igbinary extension is installed, DefaultMarshaller picks it up automatically, producing smaller payloads and faster unserialization; for an aggregate that is a large array of DTOs, the difference is noticeable in both Redis memory and request time. Wrapping in DeflateMarshaller compresses further at the cost of CPU. Measure before enabling compression; for small values it is a net loss.
Choose redis_tag_aware rather than plain redis from the beginning. Switching later means every existing key layout changes and the cache goes cold on deploy, which on a busy Monday morning is the stampede you spent this whole article avoiding.
Finally, put a local layer in front of Redis. ChainAdapter with an ArrayAdapter (per request) or ApcuAdapter (per server) removes repeated round trips for values read many times in one request. The dashboard reads the tenant's plan definition in a dozen places; without a chain that is a dozen Redis calls per request.
Putting the layers together for the dashboard
Here is how the three caches divide the work for the multi-tenant dashboard.
The HTML shell is served by Varnish with s-maxage=300 and ESI fragments for the tenant-wide widgets. Those fragments carry cache tags for the tenant, and the invoice and project update handlers purge those tags. A user in tenant 42 sees a fresh widget seconds after a colleague changes an invoice; a user in tenant 43 is unaffected.
The personalized numbers (assigned tasks, unread notifications) load from a JSON endpoint. That endpoint is marked private for HTTP purposes and uses the application cache with keys scoped by tenant and user, tagged by both, with a 60-second TTL and stampede protection handling the top-of-the-hour rush when everyone opens their dashboard at once.
Reference data (plan limits, currencies, status labels) comes from the Doctrine result cache with a one-hour TTL, sitting behind an APCu chain so that each server hits Redis at most once per hour for those tables.
Invalidation flows in one direction. A domain event fires, a subscriber invalidates the application cache tags for the tenant and purges the matching HTTP cache tags. Nothing ever touches the Doctrine result cache, because it holds nothing tenant-specific.
The failure modes this design avoids are the ones we see most often in performance audits: a global cache flush on every write, personalized data leaking between users through a misconfigured public header, and aggregate queries that stampede the database at expiry. Every one of those is a layering mistake, and no amount of Redis tuning fixes a layering mistake.
Where to start if your app has none of this
If you are retrofitting caching into an existing Symfony application, the order matters. Start with the application cache and tags, because that is where the biggest savings usually are and the risk is lowest. Add stampede protection for free by using the contracts API. Then measure, and only add HTTP caching for the endpoints that still dominate the profiler. Doctrine's result cache comes last, and only for reference data.
If the codebase has grown to the point where nobody is sure which layer is caching what, that is a common situation and a fixable one. We do this kind of work regularly as part of legacy code optimization and larger custom software development engagements. Send a note to hello@wolf-tech.io or have a look at wolf-tech.io, and we can talk through what your specific request path looks like.

