Caching is the cheapest 10x you will ever ship. A query that takes 800ms drops to a sub-millisecond read, your database stops sweating, and you change two lines of code to do it. The problem is that Laravel caching has a shallow end and a deep end, and most tutorials never leave the shallow end. They show you Cache::remember, tell you to install Redis, and call it a guide.
This is the deep end. We'll cover the drivers and what each one actually unlocks, stale-while-revalidate with Cache::flexible, cache tags for surgical invalidation, atomic locks to kill cache stampedes, and — the part nobody enjoys — an invalidation strategy that doesn't fall apart the moment your data changes. Everything here is current as of Laravel 13.
What you'll learn#
- How to choose a cache driver and what features (tags, locks) each one supports
- The difference between
Cache::remember,rememberForever, andCache::flexible - How to group related entries with cache tags and invalidate exactly what changed
- How atomic locks prevent cache stampedes and duplicate expensive work
- A disciplined cache invalidation strategy driven by model events and predictable keys
- How to cache Eloquent results without quietly reintroducing N+1 queries
- How to test caching behaviour so it doesn't silently break
How caching in Laravel actually works#
Every cache strategy in this guide is a variation on one idea: cache-aside (also called lazy loading). Your code asks the cache for a value; on a hit it returns immediately, on a miss it computes the value, writes it to the cache, and returns it. Laravel's Cache::remember is cache-aside in a single method call, which is why it's the first thing everyone learns.
The unified Cache facade sits in front of every backend, so the API is identical whether you're hitting an in-memory array store in tests or a Redis cluster in production. That portability is the whole point: you write Cache::get() once and swap the engine via config/cache.php.
The harder questions are what to cache and for how long. Good candidates are expensive and read far more often than they change: aggregate dashboard stats, rendered Markdown, an API response you assemble from three services, a permissions matrix. Bad candidates are data that must be correct to the millisecond (account balances, stock levels at checkout) or data so large that serialising it costs more than the query you're trying to avoid. If you're streaming a million rows, you want lazy collections for large datasets, not a cache entry — caching is for hot, reusable, bounded values.
The other half of the decision is TTL (time to live). A short TTL keeps data fresh but does less work for you; a long TTL is fast but risks serving stale content. The rest of this guide is largely about escaping that trade-off — with background refresh, with tags, and with event-driven invalidation — so you can keep long TTLs and fresh data.
Choose the right Laravel cache driver#
Since Laravel 11, new applications default to the database cache driver, which works out of the box with no extra infrastructure. It's fine for low-traffic apps, but it can't do two things you'll want the moment you get serious: cache tags and the full range of lock behaviour. Here's how the built-in drivers compare.
| Driver | Speed | Cache tags | Atomic locks | Best for |
|---|---|---|---|---|
array |
In-memory (per request) | Yes | Yes | Tests |
file |
Disk | No | Yes | Local dev, tiny apps |
database |
DB round-trip | No | Yes | Default; small/single-node apps |
redis |
RAM | Yes | Yes | Production |
memcached |
RAM | Yes | Yes | Production (simple key/value) |
dynamodb |
Managed | No | Yes | Serverless / Lambda |
The headline: cache tags are only supported on redis, memcached, and array. They are not supported on file, database, or dynamodb, and calling Cache::tags() on those drivers throws an exception. Atomic locks are more widely available — redis, memcached, dynamodb, database, file, and array all support them — but in a multi-server deployment every node must talk to the same central cache server for locks to mean anything.
For production, Redis is the default choice and the one I reach for on almost every project. It's in-memory, it supports tags and locks, and the same Redis instance powers your queues, sessions, and broadcasting. Install the PhpRedis extension (Laravel Sail and Laravel Cloud ship with it), configure your Redis connection, and set CACHE_STORE=redis. If you specifically want to avoid a Redis dependency on a single-server setup, that's a legitimate choice — the same trade-off comes up with WebSockets, which is why Laravel 13's Reverb database driver without Redis exists — but you give up tags, and you should know that going in.
// config/cache.php — the default store is read from the env
'default' => env('CACHE_STORE', 'database'),
# .env (production)
CACHE_STORE=redis
REDIS_CLIENT=phpredis
One last point that bites multi-server teams: anything that uses the cache as shared state — rate limiters, locks, session data — needs a shared store. If each node has its own file cache, your counters drift. This is exactly why fine-grained rate limiting on API routes insists on CACHE_STORE=redis once you scale past one box.
Cache-aside with Cache::remember and rememberForever#
Cache::remember is the workhorse. Give it a key, a TTL, and a closure; it returns the cached value or runs the closure, stores the result, and returns it.
use Illuminate\Support\Facades\Cache;
$stats = Cache::remember('dashboard:stats', now()->addMinutes(10), function () {
return [
'users' => User::count(),
'revenue' => Order::whereMonth('created_at', now()->month)->sum('total'),
'open_tickets' => Ticket::whereNull('resolved_at')->count(),
];
});
The TTL can be an integer number of seconds or a DateTime — now()->addMinutes(10) reads better than 600 and survives code review without a comment. On a hit, none of the queries run.
rememberForever is the same thing with no expiry:
$navigation = Cache::rememberForever('nav:menu', function () {
return MenuItem::with('children')->ordered()->get();
});
The word "forever" is a trap. A value with no TTL never self-heals — if the underlying data changes and you don't explicitly invalidate, you serve stale content indefinitely. rememberForever is only safe when you pair it with reliable invalidation (covered below). When in doubt, give it a TTL; a stale value that fixes itself in ten minutes is a much smaller bug than one that's wrong until the next deploy.
A quick note on closures and $this: the closure captures whatever you reference, so caching a method result is as simple as wrapping the body. Livewire builds directly on this primitive — its persisted computed properties are cache-aside under the hood, which is worth understanding if you lean on caching heavy queries across requests in Livewire.
Serve stale data with Cache::flexible#
Cache::remember has one ugly moment: the instant the key expires, the next request pays the full cost of regenerating it while the user waits. Under load, that's not one slow request — it's a cache stampede, which we'll get to.
Cache::flexible, Laravel's implementation of the stale-while-revalidate pattern, softens the cliff. It takes a key, a two-element array, and a closure:
$value = Cache::flexible('dashboard:stats', [300, 3600], function () {
return $this->expensiveStats();
});
Read the array as [fresh, stale] in seconds:
- 0–300s (fresh): the cached value is returned immediately. Nothing else happens.
- 300–3600s (stale): the cached value is still returned immediately, but Laravel registers a deferred function that refreshes the value in the background after the response is sent. The user who triggered the refresh doesn't wait for it.
- After 3600s (expired): the value is gone; the next request recomputes synchronously and eats the latency.
The win is that for the entire stale window, users get an instant response and the cache quietly heals itself. For read-heavy pages where "a few minutes out of date" is acceptable, flexible is often the single best change you can make. It relies on deferred functions, which run after the response is dispatched, so it shines on traditional FPM requests; just be aware the refresh happens on the next request during the stale window, not on a timer.
Group related entries with Laravel cache tags#
Single keys are easy to invalidate: you know the key, you call Cache::forget(). The problem is related keys. Say you cache a rendered blog post, its comment count, and its position in three different listing pages. When the post changes, you need to clear all of them — and you may not even know every key.
Cache tags solve this. You attach one or more tags when you store a value, then flush by tag:
use Illuminate\Support\Facades\Cache;
// Store with two tags: a shared group tag and a per-post tag.
Cache::tags(['posts', "post:{$post->id}"])
->put("post:{$post->id}:rendered", $html, now()->addHour());
// Retrieve — you must pass the same tags used to store it.
$html = Cache::tags(['posts', "post:{$post->id}"])
->get("post:{$post->id}:rendered");
The two-tag pattern is the one to internalise. Every cached fragment for a post gets a shared posts tag and a unique post:{id} tag. Now you can invalidate at two granularities:
// One post changed — clear only its entries.
Cache::tags("post:{$post->id}")->flush();
// Something global changed (a sitewide setting) — clear every post.
Cache::tags('posts')->flush();
That's surgical invalidation: you nuke exactly what changed and leave the rest of the cache warm. Compare that to Cache::flush(), which empties the entire store — including other applications sharing that Redis instance, because flush ignores your cache prefix.
Two caveats from the field. First, tagged items can only be read back with the same tags you wrote them with — a tag isn't a search index, it's a grouping key. Second, on Redis, Laravel maintains a set of the keys belonging to each tag, and on older patterns those sets could accumulate references over time. Keep tag names bounded and meaningful (posts, post:42), never user-supplied free text, and you'll avoid the memory-growth gotcha people run into.
Prevent a cache stampede with atomic locks#
A cache stampede (or thundering herd) happens when a popular key expires and dozens of concurrent requests all miss simultaneously. Every one of them runs the expensive closure at once, hammering your database with identical work at the worst possible moment. Cache::flexible avoids this for the stale window, but a truly cold key — first deploy, after a flush, or past the stale window — is still exposed.
Atomic locks fix it by guaranteeing only one process does the work. Cache::lock gives you a distributed mutex:
use Illuminate\Support\Facades\Cache;
$lock = Cache::lock('reports:annual', 10);
if ($lock->get()) {
// Only one process reaches here. Do the expensive work, then release.
$report = $this->buildAnnualReport();
$lock->release();
}
Pass a closure and Laravel releases the lock for you automatically, even if the closure throws. To make a stampede-proof "remember", combine a lock with a re-check so waiters use the value the winner just computed instead of recomputing it:
use Closure;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
function rememberLocked(string $key, int $ttl, Closure $callback): mixed
{
// Fast path: already cached.
if (! is_null($value = Cache::get($key))) {
return $value;
}
try {
// Hold the lock for 10s; wait up to 5s for it to free up.
return Cache::lock("{$key}:lock", 10)->block(5, function () use ($key, $ttl, $callback) {
// Re-check: a competing request may have populated it while we waited.
return Cache::remember($key, $ttl, $callback);
});
} catch (LockTimeoutException $e) {
// Couldn't acquire in time — serve stale if we have it, else compute directly.
return Cache::get($key) ?? $callback();
}
}
The critical detail is lock duration: the lock must outlive the work it protects. A 10-second lock around a 30-second report build expires mid-computation, lets a second worker in, and you're back to duplicate work. Size the lock for your slowest case.
Locks are useful well beyond caching — deduplicating webhook deliveries, ensuring a scheduled task runs once across a fleet, serialising access to a flaky third-party API. Laravel's job layer wraps the same primitive in WithoutOverlapping middleware, which is a recurring theme when scaling Laravel queues in production. For controlled parallelism rather than a hard single-owner lock, Laravel 13 also ships Cache::funnel()->limit(3) to cap concurrent executions at N.
A Laravel cache invalidation strategy that scales#
Phil Karlton's line — "there are only two hard things in computer science: cache invalidation and naming things" — is a caching joke for a reason. Reading from a cache is trivial. Knowing precisely when a cached value is wrong, and clearing exactly that value, is the entire game. A strategy that scales rests on three pillars.
1. Predictable key naming. Treat keys as a namespace with colons and stable identifiers: post:42:rendered, user:7:permissions, team:3:dashboard. Never build keys from unbounded or user-supplied input, or you'll leak memory with millions of one-off entries. If you run multi-tenant, prefix by tenant so one tenant's flush can never touch another's data — a pattern that falls naturally out of Laravel 13 multi-tenancy with teams.
2. Event-driven invalidation via model observers. The cleanest place to invalidate is the model itself, so every code path — controller, queued job, Artisan command, Tinker session — triggers it. Generate an Eloquent observer with php artisan make:observer PostObserver --model=Post:
namespace App\Observers;
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
class PostObserver
{
public function saved(Post $post): void
{
$this->flush($post);
}
public function deleted(Post $post): void
{
$this->flush($post);
}
private function flush(Post $post): void
{
Cache::tags("post:{$post->id}")->flush(); // this post's fragments
Cache::tags('posts')->flush(); // listings/index caches
}
}
Wire it up with the #[ObservedBy] attribute — no service provider edits needed in Laravel 13:
use App\Observers\PostObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
#[ObservedBy(PostObserver::class)]
class Post extends Model
{
// ...
}
saved fires on both create and update, so one method covers most mutations. If you're on a non-taggable driver, invalidate by explicit key instead — which is exactly why the naming convention matters:
Cache::forget("post:{$post->id}:rendered");
Cache::forget('posts:index');
3. TTL as a safety net. Even with perfect event-driven invalidation, give entries a sane TTL. It's your backstop for the invalidation you forgot to write or the out-of-band database change an observer never sees. Belt and braces.
Cache Eloquent results without N+1#
Caching Eloquent results is where a subtle, expensive bug hides. Consider:
// BAD: relationships are not eager-loaded inside the cached payload.
$posts = Cache::remember('posts:index', now()->addMinutes(15), function () {
return Post::latest()->take(20)->get();
});
foreach ($posts as $post) {
echo $post->author->name; // lazy-loads author on every iteration
}
The query you cached doesn't include author, so iterating the cached collection still fires a query per post — the classic N+1 — and it happens on cache hits too, which makes it maddening to spot. Eager-load inside the closure so the relations are baked into what you store:
// GOOD: relations are part of the cached payload.
$posts = Cache::remember('posts:index', now()->addMinutes(15), function () {
return Post::with(['author', 'tags'])->latest()->take(20)->get();
});
Two more habits worth forming. Cache the shape you actually render — a lean DTO or array is cheaper to serialise and deserialise than a graph of hydrated models with every attribute and relation attached. And for API responses specifically, cache after you've assembled the resource, not the raw models; if you're building responses with Laravel 13 JSON:API resources, the compound document you return is a far better cache unit than the underlying query.
Advanced Laravel caching patterns#
A few patterns that separate a cache that survives production from one that causes incidents.
Per-request memoization. If the same key is read many times within a single request or job, even a Redis round-trip per read adds up. Laravel 13's memo driver resolves a value once from the underlying store, then serves it from PHP memory for the rest of that execution:
$value = Cache::memo()->get('feature:flags'); // hits Redis once...
$value = Cache::memo()->get('feature:flags'); // ...then memory for the rest of the request
The memo store is scoped to the current request or job and is cleared between them — so it's safe even on long-lived workers.
Octane and worker memory. When you run Laravel on Octane with FrankenPHP, the framework boots once and stays resident. The Cache facade behaves normally, but any cached state you hold in a static property or singleton persists between requests and can leak across users. Keep persistent state in the cache store, not in process memory.
Cache prefixes and shared stores. Laravel namespaces every key with config('cache.prefix'). Two apps pointed at the same Redis database with the same prefix will read each other's keys, and Cache::flush() ignores the prefix entirely — it clears the whole database. Give each app its own prefix (or its own Redis database) and prefer tag-based invalidation over flush.
Failover. Laravel 13 ships a failover cache driver that falls through to a secondary store if the primary errors, dispatching a CacheFailedOver event so you can alert on it. It's a pragmatic safety net for keeping a degraded app serving when Redis hiccups.
Testing your caching#
Cache bugs are invisible until production, so test the behaviour, not just the happy path. The test environment uses the array driver by default, which supports tags — so you can verify invalidation end to end.
The single most valuable test asserts that a mutation busts the cache:
use App\Models\Post;
use Illuminate\Support\Facades\Cache;
it('busts the post cache when the model is saved', function () {
$post = Post::factory()->create();
Cache::tags("post:{$post->id}")
->put("post:{$post->id}:rendered", '<p>cached</p>', now()->addHour());
expect(Cache::tags("post:{$post->id}")->get("post:{$post->id}:rendered"))
->toBe('<p>cached</p>');
$post->update(['title' => 'Changed']); // fires the observer
expect(Cache::tags("post:{$post->id}")->get("post:{$post->id}:rendered"))
->toBeNull();
});
For TTL behaviour, travel through time rather than calling sleep():
it('expires dashboard stats after the TTL', function () {
Cache::put('dashboard:stats', ['users' => 1], now()->addMinutes(10));
$this->travel(11)->minutes();
expect(Cache::get('dashboard:stats'))->toBeNull();
});
And when you only care that a value would be cached, fake the facade and assert the interaction with Cache::shouldReceive('remember')->once(). In production, close the loop with observability: cache hit/miss ratio is a metric worth watching, and tools like Telescope and Pulse surface it — see Telescope vs Debugbar vs Pulse for which fits where.
Common Laravel caching mistakes#
The recurring ones, distilled from years of code review and the issues people file:
- Calling
Cache::tags()on an unsupported driver. It throws onfile,database, anddynamodb. If tags are core to your design, your production driver must be Redis or Memcached — decide that up front. Cache::flush()as an invalidation strategy. It empties the entire store, ignores your prefix, and on a shared Redis instance takes other apps down with it. Reach for tags or targetedforget.rememberForeverwith no invalidation. Guaranteed permanent staleness. Either invalidate on every relevant model event or give it a TTL.- Caching un-eager-loaded models. N+1 on cache hits, plus heavy serialisation. Eager-load inside the closure and cache lean shapes.
- Locks shorter than the work they protect. The lock expires mid-task, a second worker starts, and you've reinvented the stampede. Size locks for the slowest run.
- Unbounded keys. Keys built from user input or unbounded IDs fill Redis with single-use entries. Keep the keyspace small and predictable.
- Caching volatile data. Stock levels at checkout and account balances should not be cached. Cache what's read often and changes rarely.
Wrapping up#
Caching in Laravel is a ladder, not a single rung. Start with Cache::remember for cache-aside reads, reach for Cache::flexible when you want instant responses with background refresh, group related fragments with cache tags so you can invalidate surgically, and guard expensive cold builds with atomic locks. Then spend your real effort on the hard part — an invalidation strategy built on predictable keys and model observers, with TTLs as a safety net.
Get those pieces right and caching stops being a source of "why is this data stale?" tickets and becomes the boring, reliable performance win it should be. From here, three good next steps: harden the work that feeds your cache by scaling your queues in production; push per-request caching into your UI with Livewire's persisted computed properties; and make sure the rest of your stack is current with the complete Laravel developer toolchain for 2026.
FAQ#
How does caching work in Laravel?
Laravel provides a unified Cache facade in front of pluggable backends (Redis, Memcached, database, file, array, DynamoDB), configured in config/cache.php. The dominant pattern is cache-aside: you ask the cache for a key, and on a miss you compute the value, store it with a TTL, and return it. Cache::remember wraps that whole flow in one call, so subsequent reads skip the expensive work entirely until the entry expires or you invalidate it.
What is the difference between Cache::remember and Cache::rememberForever?
Both store the result of a closure and return the cached value on subsequent calls. Cache::remember($key, $ttl, $closure) takes a TTL and the entry self-expires after that window, so even without explicit invalidation it eventually refreshes. Cache::rememberForever($key, $closure) stores the value with no expiry, so it lives until you manually forget it — which means it will serve stale data indefinitely unless you pair it with reliable, event-driven invalidation.
How do cache tags work in Laravel?
Cache tags let you attach one or more labels to an entry when you store it, then flush every entry sharing a tag in one call. You write with Cache::tags(['posts', 'post:42'])->put(...) and must read back with the same tags. To invalidate, call Cache::tags('post:42')->flush() to clear one post or Cache::tags('posts')->flush() to clear them all. Tags are only supported on the Redis, Memcached, and array drivers — not file, database, or DynamoDB.
How do I prevent a cache stampede in Laravel?
A stampede happens when a hot key expires and many concurrent requests recompute it at once. Two tools help: Cache::flexible serves stale data and refreshes in the background so users rarely hit a cold key, and atomic locks (Cache::lock) ensure only one process rebuilds a truly cold value while others wait for the result. For very expensive computations, combine a lock with a Cache::remember re-check inside the locked section, and make sure the lock duration outlives the work.
What are atomic locks in Laravel?
Atomic locks are distributed mutexes built on the cache layer. Cache::lock('name', $seconds) acquires a lock that only one process across your infrastructure can hold, which is how you guarantee a piece of work runs exactly once — deduplicating webhooks, serialising access to a shared resource, or preventing duplicate cache rebuilds. You can wait for a lock with ->block($seconds), pass a closure for automatic release, or hand the owner token to a queued job to release it elsewhere.
How do I invalidate cache when a model changes?
Put the invalidation in a model observer so it fires from every code path that mutates the model, not just one controller. Generate one with php artisan make:observer, clear the relevant tags or keys in its saved and deleted methods, and attach it to the model with the #[ObservedBy] attribute. Tag each cached fragment with both a shared group tag and a per-record tag so the observer can clear exactly what changed, and keep a TTL as a backstop for anything an observer can't see.
Which cache driver should I use in production?
Redis is the standard production choice: it's in-memory, supports both cache tags and atomic locks, and the same instance can back your queues, sessions, and broadcasting. Memcached is a solid alternative for plain key/value caching but lacks Redis's broader feature set. Avoid the file and database drivers in production beyond low-traffic apps — they can't do tags and they're slower — and remember that in a multi-server deployment all nodes must share one central cache server.
Why are my cache tags not working?
The most common cause is the driver. Calling Cache::tags() on the file, database, or dynamodb driver throws or silently misbehaves because those stores don't support tagging — switch to Redis or Memcached. The second cause is mismatched tags: you must retrieve an entry with the exact same set of tags you used to store it, since the tags are part of how Laravel namespaces the key. Finally, keep tag names bounded and predictable so the underlying Redis tag sets don't grow unbounded over time.
When should I use Cache::flexible instead of Cache::remember?
Use Cache::flexible when you have a read-heavy value that's acceptable to serve slightly stale, and you want to avoid the latency spike Cache::remember causes the moment a key expires. flexible returns the cached value instantly during both its fresh and stale windows, refreshing in the background after the response is sent, so users almost never wait on regeneration. Stick with plain remember when the data must be exactly current the instant it's recomputed, or when a short, predictable expiry is simpler to reason about.