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 candidatesBad candidates
Third-party API responsesA user’s current account balance
Aggregate counts and dashboardsAnything used to make an authorisation decision
Navigation menus, settings, feature flagsData that changes on almost every read
Rendered fragments of a heavy pageCheap queries on an indexed primary key
Expensive report calculationsRows 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

DriverSpeedShared across serversSupports tags
arrayInstantNo — dies with the requestYes
fileSlow-ishNoNo
databaseSlowYesNo
redisVery fastYesYes
memcachedVery fastYesYes
# .env
CACHE_STORE=redis            # Laravel 11+  (CACHE_DRIVER on older versions)
REDIS_CLIENT=phpredis

Use 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=Post
class 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 them

Once 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=array in phpunit.xml so 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.