YOUR DIGITAL HUB
← Back to blog

PHP caching strategies: OPcache, Redis, Varnish, CDN and the invalidation problem

· 10 min read
Cover — PHP caching strategies

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: public on a page containing a username. Guaranteed leak. Always verify that the cache key includes user_id or tenant_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-Language header, 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.