Laravel

Cache Layers and Their Invalidation

Tags, TTLs and stampedes, with an explicit rule for what may go stale.

Chapter 6 of 1312 min readOpen access

Cache Layers and Their Invalidation is the chapter about the layer teams add first and specify last. A cache is a rule about what may be wrong and for how long, and if nobody wrote the rule down, the cache is not a performance feature. It is an undocumented correctness change.

Key takeaways

  • Laravel configures a fresh application to use the database cache driver, so a team that assumed Redis may be caching into the database it was trying to protect.
  • Cache tags are unavailable on the file, dynamodb, database and storage drivers, and Cache::flush does not respect the configured cache prefix and removes all entries.
  • Cache::flexible takes a fresh duration and a stale duration: inside the stale window it returns the old value immediately and registers a refresh that runs after the response has been sent.
  • Redis documents maxmemory as defaulting to zero, meaning no limit, on 64-bit systems, and its eviction page does not state a default maxmemory-policy, so both are decisions you own.
  • Cache health reads as keyspace_hits / (keyspace_hits + keyspace_misses) * 100, with evicted_keys distinguishing a policy problem from expired_keys, which indicates a TTL that is too short.

Read this after Chapter 2, because a stampede is a queue rather than a cache miss, and after Chapter 5, because a cache is often how teams avoid a lock they could have narrowed instead. Chapter 9 handles the same staleness question one layer out, where the caches belong to browsers and shared proxies rather than to you.

A dashboard query takes seconds, so somebody wraps it in a cache with a five-minute TTL. It works. The page is fast for a quarter.

Then the entry expires during peak. Forty requests arrive in the same second, find nothing, and all forty run the seconds-long query at once. The database saturates, the requests time out, nothing gets written back, and the next second does it again.

The cache did not fail. It expired, which is the thing caches do.

A cache is a written rule about what may be stale

Before any driver or TTL, a cache needs a sentence per key, and the sentence has three parts. What this key holds, how wrong it is allowed to be, and who notices when it is wrong.

That sounds like paperwork until you try to write it for a live application. Most keys turn out to fall into two groups. Values where staleness is invisible, such as a navigation tree or a country list. And values where staleness is a support ticket, such as a balance, a stock count or a permission.

Those two groups deserve different machinery, and mixing them is the origin of most cache incidents I have seen. A single TTL applied to both means either the safe keys are refreshed pointlessly or the dangerous ones go stale silently.

So write the rule first. The driver choice follows from it.

A fresh Laravel application caches in the database

This is the detail that surprises teams who assumed otherwise. Laravel's cache documentation states that by default Laravel is configured to use the database cache driver, storing serialised cached objects in your application's database.

Read the consequence carefully. If the thing you were protecting was the database, and you cached into the database, you added a write path and a read path to the resource under pressure. It still helps, because a keyed lookup is cheaper than an aggregate. It helps less than everyone assumed, and it fails at the same time as the thing it protects.

The useful mental model is a ladder of layers, each faster and each narrower in scope. In-request memoisation, which Laravel exposes as the memo driver, is the fastest and lives for one request. The Octane cache lives in one server's memory and Chapter 8 explains what that costs. A shared store like Redis or Memcached lives across processes. The database and file drivers are the durable, slow end.

Pick the highest layer that satisfies the rule you wrote. A value that only needs to be consistent within one request never needs a network hop.

Tags are unavailable exactly where you would want them

Cache tags are the feature people design invalidation around, and Laravel's documentation is clear that they are not universally available. Tags are not supported on the file, dynamodb, database or storage drivers.

Since database is the default driver, a team that designs a tag-based invalidation scheme without changing drivers has designed something that does not run. It throws on the first call. That is the good case.

The slower failure is Cache::flush. Laravel documents that flushing does not respect the configured cache prefix and removes all entries from the cache. On a shared Redis instance serving several applications, or serving your queues and your sessions, that is a much larger blast radius than the method name suggests.

The rule I hold to: never call a global flush from application code. Invalidate by key, or by tag on a driver that supports tags, or by writing a new key and moving a pointer.

The stampede is a queue, not a cache miss

Chapter 2's model explains the failure in the scene above better than any cache vocabulary does. At the moment of expiry, arrival rate is unchanged and service time jumps from a cache read to a full recomputation. Utilisation goes past one instantly.

There are two structural answers and Laravel ships both. The first is to make only one caller recompute. Laravel's atomic locks do that, and its documentation records the constraint: locks require the application default cache driver to be memcached, redis, dynamodb, database, file or array, and all servers must communicate with the same central cache server. The withoutOverlapping and funnel helpers wrap the same primitive in shapes that fit jobs and tasks.

The second is to make nobody wait. That is what a stale window is for, and it is the subject of the next section.

Both are better than a longer TTL. A longer TTL delays the stampede and makes it bigger, because more callers accumulate behind a colder, more expensive recomputation. Expiry is not the bug. The thundering recompute is.

Cache::flexible gives you three windows

Laravel's Cache::flexible method takes an array with two durations, a fresh duration and a stale duration, and produces three distinct behaviours from one call.

Inside the fresh window, the cached value is returned. Inside the stale window, the old value is returned immediately and a refresh is registered that runs after the response has been sent to the user, so nobody waits for it. Past the stale window the value is recomputed inline and the caller does wait.

That pattern has a lineage worth knowing, because it makes the design intent legible. RFC 5861, published by the RFC Editor in May 2010 as an Informational document, defined stale-while-revalidate and stale-if-error for HTTP caches. Laravel's flexible cache is the same idea applied to an application-level store, and Chapter 9 uses the HTTP original.

Laravel 13 adds two neighbours. Its release notes for 17 March 2026 name Cache::touch, which extends an existing item's time to live without retrieving and re-storing the value, and the framework also documents rememberWithWarmth for warming an entry ahead of its expiry.

The decision rule is short. If a value can be one refresh interval out of date without anybody being harmed, it belongs in a flexible cache rather than a plain one.

Eviction policy is a decision, not a default you can assume

A cache with no memory limit and no eviction policy is not a cache. Redis's documentation gives you the exact reason, and it is a documented default rather than an opinion.

Setting maxmemory to zero means no limit on the dataset, and Redis's eviction page states that zero is the default behaviour on 64-bit systems, with 32-bit systems using an implicit three gigabyte limit. So an unconfigured 64-bit Redis grows until something else on the machine loses. The same page notes that replication and persistence buffers are excluded from the total compared against maxmemory, reported as mem_not_counted_for_evict, which is why you leave headroom.

The maxmemory-policy directive selects the policy. The available names are noeviction, then lru, lrm, lfu and random in both an allkeys and a volatile family, plus volatile-ttl. Least Recently Modified is the newest of them, available starting with Redis 8.6, and it updates its timestamp only on writes rather than on reads.

Three documented details change how you read the graphs. The volatile policies behave like noeviction when no key has an expiration set. Redis LRU is an approximation that samples keys, tuned by maxmemory-samples. And LFU uses a Morris counter that Redis documents as saturating at around one million requests and decaying every minute by default, tuned with lfu-log-factor and lfu-decay-time.

Redis's own rule of thumb, on that page, is that allkeys-lru is a good default when you expect a subset of keys to be accessed far more often than the rest. That is their recommendation, not a measurement of mine.

Write down what may go stale, and for how long

This is the artifact the chapter exists to produce. One table, kept in the repository, one row per cached thing.

Cached valueAllowed stalenessMechanismWho notices if wrong
Navigation and taxonomyHoursLong TTL, flexible windowNobody, until a term is renamed
Aggregate dashboard countsMinutesFlexible, stale window covers refreshAn analyst, mildly
Permission or role checksZeroNot cached, or invalidated on writeA user, immediately, as a security event
Account balanceZero for the ownerRead through, or per-request memoisation onlyThe customer, loudly
Rendered public page fragmentMinutesTagged cache, invalidated on publishAn editor, within one refresh

The middle row is the one worth arguing about in a room. Permissions are cached in more applications than anybody admits, and the reason is always that permission resolution was slow. Chapter 3 is the right place to fix that, not this chapter.

A worked reading, composited and labelled

The details here are composited from ordinary production shapes rather than one system, and the shape is exact.

Cache effectiveness is reported as good because the hit ratio is high. The first reading computes that ratio from Redis's own counters, as keyspace_hits over hits plus misses, and pairs it with evicted_keys and expired_keys on the same axis. High hits with climbing evictions is a different diagnosis from high hits with climbing expiries.

Redis's documentation gives you the interpretation directly. A high proportion of evictions suggests the wrong keys are being evicted by the chosen policy. A low eviction count with a high expired_keys count suggests the TTL is too short, or that the wrong keys were given one.

The second reading is the same three series after the change, whether that change was a policy, a memory limit or a TTL. What you are looking for is a shift in which counter moves, not a percentage. The ratio alone would have told you nothing, which is the point.

The objection: just add Redis

Adding a fast shared store is a good move that gets asked to do a bad job. Two of those jobs are worth naming, because both are common and both fail late.

The first is caching a slow dependency to avoid fixing it. A cached response to a third-party API that takes four seconds does not make the dependency fast, it makes the first caller of every window absorb it. The fix belongs at the call site, with a timeout and a fallback, and Chapter 7 covers the retry contract that goes with it.

The second is treating the cache as available. It is a network service with its own capacity, and Laravel documents a failover cache driver that moves to another store and dispatches a CacheFailedOver event when the first one is unreachable. Configure that deliberately, and decide what your application does when the cache is gone rather than discovering it.

There is a smaller point that matters under high call volume. Laravel documents that cache events can be disabled per store, which is worth doing on a store you call thousands of times per request cycle, because every interaction otherwise dispatches an event nobody is listening to.

Chapter summary

A cache is a written rule about what may be wrong and for how long, with a named party who notices, and the two groups of value, invisible staleness and support-ticket staleness, need different machinery rather than a shared TTL. Laravel defaults a fresh application to the database cache driver, which means a team caching to protect the database may be caching into it, and the useful model is a ladder from per-request memoisation through the Octane cache to a shared store and finally the durable drivers. Tags are unavailable on file, dynamodb, database and storage, and Cache::flush ignores the configured prefix and removes every entry, which on a shared Redis is a far wider blast radius than the name implies. A stampede is Chapter 2's queue arriving at the instant of expiry, answered either by letting one caller recompute under an atomic lock, which requires a central cache server and one of the documented drivers, or by nobody waiting at all, which is what Cache::flexible provides through a fresh window, a stale window whose refresh is deferred until after the response is sent, and an expired state that recomputes inline. That pattern descends from RFC 5861, published May 2010, which defined stale-while-revalidate and stale-if-error. Redis documents maxmemory as unlimited by default on 64-bit systems and states no default eviction policy, so an unconfigured instance grows until something else loses, and the diagnostic pair is evicted_keys against expired_keys beside the hit ratio. And adding Redis does not fix a slow dependency; it moves the cost to whoever arrives first in each window.

Deferring work until after the response is exactly what the stale window does, and the general version of that move is a queue. Chapter 7 is Queues, Workers, and Horizon Under Load, where the pool from Chapter 2 has a longer service time and a much longer memory.

Sources

  1. CacheLaravel · 2026-07-29 · Official documentation · verified
  2. Key evictionRedis · 2026-07-29 · Official documentation · verified
  3. RFC 5861: HTTP Cache-Control Extensions for Stale ContentRFC Editor · 2010-05 · Standard · verified
  4. Release Notes, Laravel 13Laravel · 2026-03-17 · Official documentation · verified