Cache the Right Things
Cache when the work is expensive, the result is reused, and slight staleness is acceptable. All three have to hold.
| Good candidates | Bad candidates |
|---|---|
| Third-party API responses | A user’s current account balance |
| Aggregate counts and dashboards | Anything used to make an authorisation decision |
| Navigation menus, settings, feature flags | Data that changes on almost every read |
| Rendered fragments of a heavy page | Cheap queries on an indexed primary key |
| Expensive report calculations | Rows a single user reads once |
Do not reach for cache to hide a missing index or an N+1 query. Fix the query first — a cached wrong answer is worse than a slow right one, and you will still have the slow query on every cache miss.
Choosing a Driver
| Driver | Speed | Shared across servers | Supports tags |
|---|---|---|---|
array | Instant | No — dies with the request | Yes |
file | Slow-ish | No | No |
database | Slow | Yes | No |
redis | Very fast | Yes | Yes |
memcached | Very fast | Yes | Yes |
# .env
CACHE_STORE=redis # Laravel 11+ (CACHE_DRIVER on older versions)
REDIS_CLIENT=phpredisUse array in tests so nothing leaks between them, file for a single-server hobby project, and Redis for anything real. The moment you run two web servers, a file or array cache means two different answers depending on which box you land on.
The Cache API
use Illuminate\Support\Facades\Cache;
Cache::put('key', $value, now()->addMinutes(30));
Cache::put('key', $value); // forever, if the driver allows
Cache::forever('key', $value);
Cache::get('key'); // null if missing
Cache::get('key', 'default');
Cache::get('key', fn () => expensive()); // lazy default
Cache::has('key');
Cache::missing('key');
Cache::forget('key');
Cache::flush(); // nukes the entire store — careful
// Only write if the key does NOT already exist (atomic)
Cache::add('key', $value, 60);
// Counters
Cache::increment('page:views');
Cache::decrement('stock:' . $sku, 3);
// Read and delete in one step
Cache::pull('one-time-token');
// Target a specific store
Cache::store('redis')->put('key', $value, 600);Cache::flush() clears everything in the store — including sessions and queues if they share the same Redis database. Give cache its own database number, or use tags so you can clear a slice.
remember() and flexible()
remember() is the pattern you will use nine times out of ten: return the cached value, or compute it, store it and return it.
$stats = Cache::remember('dashboard:stats', now()->addMinutes(15), function () {
return [
'users' => User::count(),
'orders' => Order::whereMonth('created_at', now()->month)->count(),
'revenue' => Order::whereMonth('created_at', now()->month)->sum('total'),
];
});
// Never expires
$countries = Cache::rememberForever('countries', fn () => Country::orderBy('name')->get());Laravel 11 added flexible(), which implements stale-while-revalidate. Values stay fresh for the first duration; between the first and second they are served stale while a background refresh runs; past the second they are recomputed synchronously.
// Fresh for 5 minutes, served stale (and refreshed in the background) up to 15
$stats = Cache::flexible('dashboard:stats', [300, 900], function () {
return $this->buildExpensiveStats();
});That is the single best upgrade for a slow dashboard: nobody ever waits for the rebuild, and the data is never more than fifteen minutes old.
remember() caches null as a valid value in some cases but treats a missing key the same way — so a callback returning null re-runs on every request. If null is a legitimate result, wrap it: cache ['value' => null] instead.
Cache Tags
Tags group related keys so you can invalidate a whole category at once. Redis and Memcached only.
Cache::tags(['posts', 'user:' . $user->id])
->put('post:' . $post->id, $post, 3600);
Cache::tags(['posts'])->remember('posts:latest', 600, fn () => Post::latest()->take(10)->get());
// Clear everything tagged 'posts' — leaves other cache untouched
Cache::tags(['posts'])->flush();
// Clear one user's cached slices
Cache::tags(['user:' . $user->id])->flush();Tags are convenient but add a layer of indirection in Redis, and flushing a tag leaves orphaned entries until they expire. For a small, well-understood key set, an explicit naming convention plus targeted forget() calls is often simpler and faster.
Invalidation Strategies
Three approaches, in increasing order of correctness and effort.
1. Time-based (TTL). Simplest, and correct enough for most things. Pick a TTL you can defend: “this dashboard can be five minutes stale”.
2. Event-based. Clear the key when the underlying data changes. Model observers make this tidy:
php artisan make:observer PostObserver --model=Postclass PostObserver
{
public function saved(Post $post): void
{
Cache::forget('post:' . $post->id);
Cache::tags(['posts'])->flush();
}
public function deleted(Post $post): void
{
Cache::forget('post:' . $post->id);
Cache::tags(['posts'])->flush();
}
}3. Key versioning. Bake something that changes into the key and you never invalidate at all — old entries simply stop being requested and expire on their own.
// The key changes whenever the post is updated
$key = "post:{$post->id}:v{$post->updated_at->timestamp}";
$rendered = Cache::remember($key, 86400, fn () => $this->renderMarkdown($post->body));
// Collection-level version — one counter invalidates every derived key
$version = Cache::get('posts:version', 1);
$key = "posts:list:page:{$page}:v{$version}";
// On any write:
Cache::increment('posts:version');Key versioning is the most robust of the three because there is no invalidation step to forget. The cost is more cache entries until the old ones age out — usually a fine trade.
Locks and Cache Stampedes
A popular cached value expires. Two hundred concurrent requests all miss, and all two hundred run the expensive query at once. That is a cache stampede, and it can take a database down at exactly the moment traffic is highest.
Atomic locks let one request rebuild while the others wait:
$lock = Cache::lock('rebuild:stats', 10);
if ($lock->get()) {
try {
$stats = $this->buildExpensiveStats();
Cache::put('dashboard:stats', $stats, 900);
} finally {
$lock->release();
}
}
// Or block for up to 5 seconds waiting for whoever holds it
Cache::lock('rebuild:stats', 10)->block(5, function () {
Cache::put('dashboard:stats', $this->buildExpensiveStats(), 900);
});Locks are useful well beyond caching — they are the standard way to stop a scheduled command running twice when two servers fire the same cron:
Schedule::command('reports:nightly')->daily()->withoutOverlapping();
// Manually, anywhere
Cache::lock('import:customers', 600)->get(function () {
(new CustomerImporter)->run();
});flexible() avoids most stampedes by design, because the stale value keeps being served while one background job refreshes it.
Caching Queries and Models
// A single expensive aggregate
$revenue = Cache::remember('revenue:' . now()->format('Y-m'), 3600, function () {
return Order::whereMonth('created_at', now()->month)->sum('total');
});
// A lookup table that barely changes
$settings = Cache::rememberForever('settings', fn () => Setting::pluck('value', 'key'));
// Per-user, keyed safely
$feed = Cache::remember("feed:{$user->id}:page:{$page}", 300, function () use ($user, $page) {
return $user->timeline()->with('author')->paginate(20, ['*'], 'page', $page);
});Never build a cache key from unescaped user input. "search:" . $request->q lets a visitor fill your Redis instance with junk keys, and can collide with keys you rely on. Hash it: 'search:' . md5($request->q).
Two more things worth caching that people forget:
{{-- Blade fragment caching via a helper --}}
{!! Cache::remember("nav:{$user->role}", 3600, fn () => view('partials.nav')->render()) !!}// HTTP caching — let the browser skip the request entirely
return response($content)
->header('Cache-Control', 'public, max-age=300, s-maxage=600')
->setEtag(md5($content));Config, Route and View Caching
Separate from the cache store, and purely a deployment concern:
php artisan config:cache # merges all config into one file
php artisan route:cache # serialises the route table
php artisan view:cache # precompiles every Blade template
php artisan event:cache # caches event/listener discovery
php artisan optimize # all of the above
php artisan optimize:clear # clears all of themOnce config:cache has run, env() returns null everywhere outside config files. Any env() call in a controller, model or service will silently break in production. Read from config() instead — always.
Add php artisan optimize to your deploy script, after composer install and before restarting workers.
Mistakes to Avoid
- Caching authorisation results. A stale permission is a security bug, not a performance win.
- Forgetting the key is global.
'dashboard'is shared by every user. Include the user or tenant id in any per-user key. - Caching paginated results without the page number. Page 2 serves page 1’s data.
- Caching a full Eloquent model with loaded relations when you only need three fields — serialise an array instead.
- Long TTLs with no invalidation path. If you cannot answer “how does this get cleared?”, shorten the TTL.
- Testing with a real cache. Set
CACHE_STORE=arrayinphpunit.xmlso tests cannot pollute each other.
Measure before and after — and measure the cache-miss path too, because that is what your users hit at the worst possible moment.