PHP caching strategies: OPcache, Redis, Varnish, CDN and the invalidation problem
Cache is a promise, not a solution
There are two hard problems in computer science: naming things, invalidating the cache, and off-by-one errors. Phil Karlton's joke is true for a precise reason: a poorly designed cache amplifies bugs instead of hiding them. Stale data served to 99.9% of users is a real regression, not an optimization.
Despite the risk, cache remains indispensable. A modern PHP application stacks five cache layers between source code and browser. Each layer has its role, invariants, pitfalls. This article walks through the five layers, proven invalidation patterns and the traps we see most often in audits.
The five layers
| Layer | Location | Duration | Usage | Typical tool |
|---|---|---|---|---|
| 1 | Browser | minutes to days | Static assets, public APIs | Cache-Control, Service Worker |
| 2 | CDN | seconds to hours | Pages, public JSON, images | Cloudflare, CloudFront, Fastly |
| 3 | Reverse proxy | seconds to minutes | Public dynamic pages | Varnish, Nginx proxy_cache |
| 4 | Application | minutes to hours | Business results, queries | Redis, Memcached |
| 5 | Bytecode | until restart | Compiled PHP code | OPcache |
A layer-1 hit costs 0 ms of server time. A layer-5 hit stays inside the PHP process. The earlier you catch, the more you save. But each layer brings its invalidation complexity.
Layer 5 — OPcache, the non-negotiable base
OPcache compiles PHP bytecode once and keeps it in shared memory. Without OPcache, every request reparses and recompiles code. It is free, enabled by default since PHP 5.5, and still misconfigured on a third of the clients we audit.
Recommended production config (excerpt from php.ini or a dedicated file in /etc/php/8.3/fpm/conf.d/10-opcache.ini):
; OPcache enabled
opcache.enable = 1
opcache.enable_cli = 0
; Memory allocated (tune to code volume)
opcache.memory_consumption = 256
opcache.interned_strings_buffer = 32
; Cacheable files count (tune above the actual count)
opcache.max_accelerated_files = 20000
; Do not revalidate timestamps in prod (critical)
opcache.validate_timestamps = 0
; Keep comments: Doctrine, Symfony annotations depend on them
opcache.save_comments = 1
; Advanced optimizations
opcache.enable_file_override = 0
opcache.fast_shutdown = 1
opcache.jit_buffer_size = 64M
opcache.jit = tracing
The critical point is opcache.validate_timestamps = 0. With 1, PHP stats every file on every request to detect changes: 10 to 30% overhead for nothing. In prod, code only changes on deploy, so we disable revalidation and force a purge at deploy time.
opcache.save_comments = 1 is mandatory for applications using PHP annotations or attributes (Symfony, Doctrine). Leaving it at 0 crashes the DI container.
Measured impact: 3x to 5x faster on a typical Symfony application versus OPcache disabled. On an endpoint loading 200 classes, the gain is 40 to 60 ms per request.
Layer 4 — Application cache
Application cache stores business results: expensive queries, projections, external API calls.
Doctrine second-level cache
Doctrine offers a second-level cache for frequently read entities. Reserve it for rarely modified entities (categories, configurations, taxonomies).
# config/packages/doctrine.yaml
doctrine:
orm:
second_level_cache:
enabled: true
region_cache_driver:
type: pool
pool: doctrine.redis_cache_pool
regions:
category_region:
cache_driver:
type: pool
pool: doctrine.redis_cache_pool
lifetime: 3600
#[ORM\Entity]
#[ORM\Cache(usage: 'NONSTRICT_READ_WRITE', region: 'category_region')]
class Category
{
// ...
}
NONSTRICT_READ_WRITE is the go-to mode for read-heavy workloads. READ_WRITE adds distributed locking and is slower.
Manual Redis cache
For standard business cache, we use symfony/cache with a Redis adapter.
# config/packages/cache.yaml
framework:
cache:
default_redis_provider: 'redis://redis:6379'
pools:
app.hot_cache:
adapter: cache.adapter.redis
default_lifetime: 300
app.tag_cache:
adapter: cache.adapter.redis_tag_aware
default_lifetime: 3600
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class DashboardService
{
public function __construct(
private CacheInterface $hotCache,
private DashboardQueryBus $queryBus,
) {}
public function compute(int $tenantId): DashboardDto
{
return $this->hotCache->get(
"dashboard.{$tenantId}",
function (ItemInterface $item) use ($tenantId) {
$item->expiresAfter(300);
$item->tag(["tenant.{$tenantId}"]);
return $this->queryBus->handle(new ComputeDashboardQuery($tenantId));
}
);
}
}
Redis structures that fit the job
Redis is not only a key/value store. Using the right structures changes efficiency.
| Structure | Usage | Example |
|---|---|---|
| Strings | Classic key/value cache | SET cache:dashboard:42 "{...}" EX 300 |
| Hashes | Structured objects, partial update | HSET user:42 lastLogin "2026-06-15" |
| Sets | Dedup, intersections | SADD online:users 42 43 44 |
| Sorted sets | Leaderboards, ordered queues | ZADD trending:articles 1280 42 |
| Streams | Event log, persistent pub/sub | XADD events:invoices * type updated id 42 |
Caching patterns
- Cache-aside: the application reads Redis, falls back to DB on miss, populates Redis afterward. Most common pattern, the one used in the Symfony code above.
- Write-through: every write to DB also writes to Redis. Strong consistency, tight coupling.
- Write-behind: writes go to Redis first, async to DB. Maximum performance, risk of loss on Redis crash. Reserved for specific cases (counters).
Layer 3 — Varnish and the reverse proxy
Varnish intercepts HTTP requests before they reach PHP. On public endpoints (marketing pages, unauthenticated JSON), a Varnish hit costs 1 to 5 ms.
vcl 4.1;
backend default {
.host = "app";
.port = "80";
}
sub vcl_recv {
# Never cache authenticated requests
if (req.http.Authorization || req.http.Cookie ~ "PHPSESSID|token") {
return (pass);
}
# Never cache non-GET
if (req.method != "GET" && req.method != "HEAD") {
return (pass);
}
# Normalize headers to maximize hits
unset req.http.Accept-Encoding;
return (hash);
}
sub vcl_backend_response {
# Default 2 min TTL, 1h grace (serve stale if backend fails)
set beresp.ttl = 120s;
set beresp.grace = 1h;
# Support tag-based invalidation via application header
if (beresp.http.X-Cache-Tags) {
set beresp.http.xkey = beresp.http.X-Cache-Tags;
}
}
Varnish tag-based invalidation relies on the xkey module (vmod). The application emits X-Cache-Tags: invoice:42 tenant:7 on the response. Later, a targeted BAN invalidates all objects tagged invoice:42.
ESI: Edge Side Includes
ESI lets you cache page fragments independently. Typical example: a cache-friendly product page with a personalized "cart" block assembled by Varnish.
<esi:include src="/_fragment/cart?sid=abc123" />
Symfony supports ESI natively via the HttpKernel component. A sub-request rendered with its own cache TTL.
Layer 2 — CDN
Cloudflare, AWS CloudFront and Fastly are the CDNs we deploy most often. Their role is to cache geographically close to the user. A user in Sydney gets the response from a PoP at 10 ms instead of 300 ms from Paris.
Rules we systematically set on Cloudflare:
- Static assets (
.css,.js,.jpg,.woff2):Cache-Control: public, max-age=31536000, immutable. Versioned by hash in the filename. - Public JSON (
/api/public/*): edge TTL 60 s with stale-while-revalidate. - Public HTML pages: edge cache 5 min, purge API on publish.
- Everything else (
/admin,/api/private):bypass.
Cloudflare's purge API accepts tags via Cache-Tag. Our CI uses it after every deploy to invalidate affected tags.
curl -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags":["pages","invoices"]}'
Layer 1 — Browser and Service Worker
Browser cache is governed by Cache-Control and ETag headers. On an SPA, a Service Worker enables fine-grained control (workbox, Next.js cache).
For a REST API consumed by a mobile or SPA client, the golden rule: every GET endpoint must carry an explicit Cache-Control. By default in Symfony, responses have Cache-Control: private, must-revalidate, which prevents any caching. You have to fix each endpoint explicitly.
Invalidation: the hard problem
Correctly invalidating a cache is harder than designing it. Three strategies.
Time-based (TTL)
The simplest: each entry expires after a duration. No active invalidation needed. Fits data that tolerates short obsolescence (exchange rates refreshed every minute).
Trap: picking the right TTL. Too short, the cache is useless. Too long, users see stale data for too long.
Tag-based
Each entry carries one or more tags. You invalidate all tags at once.
// On cache write
$cache->get("invoice.42", function (ItemInterface $item) {
$item->tag(['tenant.7', 'invoice.42', 'invoices']);
$item->expiresAfter(3600);
return $this->buildDto(42);
});
// When invoice 42 changes
$cache->invalidateTags(['invoice.42']);
// Global change (rare)
$cache->invalidateTags(['invoices']);
Requires a tag-aware adapter (RedisTagAwareAdapter in Symfony, Varnish xkey, Cloudflare Cache-Tag). This is the strategy we use most often in production.
Event-based (pub/sub)
A modification emits an event on a Redis pub/sub channel. Consumers (other workers, local caches) invalidate affected entries. Useful when several application instances keep their own local L1 cache.
// Publisher
$redis->publish('cache:invalidate', json_encode([
'tags' => ['invoice.42', 'tenant.7'],
'timestamp' => time(),
]));
// Consumer in each worker
$redis->subscribe(['cache:invalidate'], function ($redis, $chan, $msg) use ($localCache) {
$payload = json_decode($msg, true);
foreach ($payload['tags'] as $tag) {
$localCache->forgetByTag($tag);
}
});
Cache stampede: the silent trap
A cache stampede happens when an entry expires and 1000 concurrent requests all hit the miss at once. They all recompute in parallel, saturate the DB, and the application collapses.
Two protections.
Distributed lock
Put a Redis lock while one request recomputes; others wait or serve stale.
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;
final class StampedeSafeCache
{
public function __construct(
private CacheInterface $cache,
private LockFactory $lockFactory,
) {}
public function get(string $key, int $ttl, callable $compute): mixed
{
$item = $this->cache->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$lock = $this->lockFactory->createLock("lock.{$key}", 30);
if (!$lock->acquire(blocking: false)) {
// Short wait, then re-read
usleep(100_000);
return $this->cache->getItem($key)->get();
}
try {
$value = $compute();
$item->set($value);
$item->expiresAfter($ttl);
$this->cache->save($item);
return $value;
} finally {
$lock->release();
}
}
}
Probabilistic early expiration
More elegant: recompute the entry before expiry, with a probability that grows as expiry approaches. No request ever sees the miss.
final class ProbabilisticCache
{
public function get(string $key, int $ttl, callable $compute): mixed
{
$item = $this->cache->getItem($key);
if ($item->isHit()) {
$meta = $item->getMetadata();
$expiresAt = $meta['expiry'] ?? 0;
$computeTime = $meta['compute_ms'] ?? 100;
$now = microtime(true);
$delta = ($computeTime / 1000) * log(mt_rand() / mt_getrandmax());
if ($now - $delta < $expiresAt) {
return $item->get();
}
}
$start = microtime(true);
$value = $compute();
$computeMs = (int) ((microtime(true) - $start) * 1000);
$item->set($value);
$item->expiresAfter($ttl);
$item->getMetadata()['compute_ms'] = $computeMs;
$this->cache->save($item);
return $value;
}
}
Based on the paper "Optimal Probabilistic Cache Stampede Prevention" (Vattani, Chierichetti, Lowenstein, 2015). We apply it on critical production caches.
Stack per application size
| Size | Minimal cache | Optimal cache |
|---|---|---|
| Solo / MVP (1 instance) | OPcache + Redis | OPcache + Redis + Cloudflare free |
| SMB (3 to 10 instances) | OPcache + Redis + Varnish | Add paid Cloudflare CDN |
| Scale-up (10+ instances) | OPcache + Redis cluster + Varnish | Fastly or CloudFront with tags, Redis sentinel |
A common temptation is to skip layers. "We have Cloudflare, we do not need Redis." False: the CDN caches public content, Redis caches private. The two are complementary.
Metrics to track
Without measurement, you cannot tell whether the cache works.
- Hit ratio per layer. Varnish hit ratio < 80%: invalidation is too aggressive. > 99%: maybe caching too long.
- Redis memory usage. If you approach
maxmemory, evictions start and hits collapse. - Redis eviction rate. Measured via
INFO stats. Above 100/sec, you need more RAM or shorter TTLs. - Cache miss latency. A miss must recompute in under 500 ms to avoid propagating slowness.
- Staleness. Average time between a data change and the new content being visible to users.
Pitfalls observed in audits
- Caching an error. If the backend raises, do not cache the response. Otherwise you serve 500s for the entire TTL.
- Accidentally caching personalized data.
Cache-Control: publicon a page containing a username. Guaranteed leak. Always verify that the cache key includesuser_idortenant_id. - Infinite TTL without bust strategy. "It never changes" becomes false one day. Always plan an invalidation mechanism, even if you do not expect to use it.
- Local L1 cache without consistency. Multiple PHP workers with their own in-memory cache. Invalidation impossible without pub/sub. We avoid this pattern unless the case truly demands it.
- Incorrect Vary. A forgotten
Vary: Accept-Languageheader, and everyone gets the first visitor's language.
Conclusion
Cache is a discipline in itself, not a bolt-on. Well applied, it divides server load by 10 or 100 and improves user experience dramatically. Badly applied, it hides bugs, serves stale data and turns a local incident into a global one.
For an audit of your PHP application's cache strategy or the design of a multi-layer cache tailored to your traffic, reach out at contact@your-digital-hub.com. See also our PHP expertise and our performance and scalability service.