Your dashboard prop hits Cache::remember with a 60-second TTL. At second 61, one unlucky visitor pays for the full recount while everyone else was fine a moment earlier. Laravel ships a one-line fix for that stampede: Cache::flexible, the stale-while-revalidate helper most apps still ignore.
TL;DR
Cache::flexible($key, [$fresh, $stale], $callback)returns the cache when it is fresh, returns it and refreshes after the response when it is stale, and only recalculates inline when it is expired or missing.- Fresh and stale are seconds from the moment the value was stored. The second number is also the real cache TTL.
- Under the hood, the stale refresh is a named
defer()with a cache lock, so concurrent requests do not all rebuild the same key. - For data that must be exact (balances, permissions), keep
rememberor no cache. Flexible is for counts, lists and other “close enough for a few seconds” props.
What Cache::flexible is and why people miss it#
Cache::flexible arrived in Laravel 11.23 (September 2024), in the same Laracon release as defer() and Concurrency. It lives on the cache docs under “Stale While Revalidate”, one heading below remember. Easy to scroll past when you already “know” the Cache facade.
The idea comes from HTTP’s stale-while-revalidate: serve what you have, refresh in the background, and only make someone wait when the entry is truly gone.
use Illuminate\Support\Facades\Cache;
$stats = Cache::flexible('dashboard.stats', [5, 30], function () {
return [
'orders_today' => Order::query()->whereDate('created_at', today())->count(),
'revenue_cents' => Order::query()->whereDate('created_at', today())->sum('total'),
];
});Pass that $stats array as an Inertia prop. For the next 5 seconds every request is a pure cache hit. From second 5 to 30, visitors still get the previous numbers instantly, and Laravel refreshes the key after the response is sent. Past 30 seconds the entry is gone, and the next visitor pays for the query.
The usual way vs the gem#
Most apps reach for Cache::remember. It is fine until the TTL ends: then one request blocks on the callback while the rest of the world waits for that same key to fill again.
remember (someone waits), then flexible (almost nobody waits):
public function __invoke()
{
$stats = Cache::remember('dashboard.stats', 30, function () {
return [
'orders_today' => Order::query()->whereDate('created_at', today())->count(),
'revenue_cents' => Order::query()->whereDate('created_at', today())->sum('total'),
];
});
return Inertia::render('Dashboard', [
'stats' => $stats,
]);
}public function __invoke()
{
$stats = Cache::flexible('dashboard.stats', [5, 30], function () {
return [
'orders_today' => Order::query()->whereDate('created_at', today())->count(),
'revenue_cents' => Order::query()->whereDate('created_at', today())->sum('total'),
];
});
return Inertia::render('Dashboard', [
'stats' => $stats,
]);
}We timed it on Laravel 13.35 with the database cache store and a callback that sleeps 80 ms (standing in for a heavy aggregate):
| Phase | What you get | Our timing | Callback runs? |
|---|---|---|---|
| Miss / expired | New value, user waits | ~82–87 ms | Yes, inline |
Fresh (0 … $fresh) | Cached value | ~0–1 ms | No |
Stale ($fresh … $stale) | Cached value now; refresh after response | ~1 ms to the client | Yes, via defer() |
Two stale hits in the same request only registered one deferred refresh (named debounce). After defer invoked, the stored value was the new one.
Here is how the options compare for a dashboard prop:
| Option | User waits on expiry? | Needs a worker? | Best for |
|---|---|---|---|
| No cache | Always | No | Tiny queries, must-be-exact data |
Cache::remember | Yes, every TTL boundary | No | Cheap callbacks, or data that cannot be stale |
Cache::flexible | Only when fully expired | No | Aggregates, public counts, “good enough” lists |
remember + queue rebuild | No (if you pre-warm) | Yes | Huge jobs you schedule yourself |
Real-world example: an Inertia dashboard#
Full controller from the test app. The Vue page just renders the props; nothing changes on the front end except that the spinner is gone on most refreshes.
<?php
namespace App\Http\Controllers;
use App\Models\Order;
use Illuminate\Support\Facades\Cache;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(): Response
{
$stats = Cache::flexible('dashboard.stats', [5, 30], function () {
return [
'orders_today' => Order::query()->whereDate('created_at', today())->count(),
'revenue_cents' => Order::query()->whereDate('created_at', today())->sum('total'),
];
});
return Inertia::render('Dashboard', [
'stats' => $stats,
]);
}
}<script setup lang="ts">
defineProps<{
stats: { orders_today: number; revenue_cents: number }
}>()
</script>
<template>
<div>
<p>Orders today: {{ stats.orders_today }}</p>
<p>Revenue: {{ (stats.revenue_cents / 100).toFixed(2) }}</p>
</div>
</template>Hit /dashboard a few times after the fresh window. The HTML (or Inertia JSON) still comes back in a millisecond, and the heavy query runs after the browser already has the page.
What the [fresh, stale] pair actually means#
Both numbers are seconds from the created timestamp, not a sliding window:
ttl[0](fresh): untilcreated + fresh, return the value and do nothing else.ttl[1](stale / absolute TTL): untilcreated + stale, return the value and register a deferred refresh. The cache entry itself is stored with a TTL ofstaleseconds, so past that point both the value and theilluminate:cache:flexible:created:…meta key are gone.- Past stale (or never stored): run the callback inline, store value + created timestamp, TTL =
stale.
So [5, 30] means: 5 seconds of pure hits, then 25 seconds of “serve old, rebuild after response”, then a real miss.
Locks and the alwaysDefer flag#
When several requests land in the stale window together, Laravel does not want every FPM worker to rebuild the same key. The refresh closure takes a cache lock (illuminate:cache:flexible:lock:…) and re-checks the created timestamp before writing. You can pass lock options as the fourth argument:
Cache::flexible('dashboard.stats', [5, 30], $callback, lock: ['seconds' => 10]);The fifth argument, $alwaysDefer, is passed through to defer(..., $always). Set it when you want the refresh to run even if the response status is 4xx/5xx. Default is false, matching normal defer() rules (see defer()).
Gotchas#
Stale means stale. The user who triggers the refresh still sees the previous value. For a homepage order count that is fine. For “can this user delete the invoice?” it is not. Do not flexible authz, balances or anything a lawyer will ask about.
Gotcha: forget() and the created key
Cache::flexible stores two keys: your value and illuminate:cache:flexible:created:{key}. On database and file stores, Cache::forget($key) also clears the created meta key. On some other drivers the meta key may linger until its own TTL; the next flexible call still recovers, but prefer forgetting through the same store you write with, and do not hand-delete only one of the two keys.
The refresh uses defer(). Same rules as the defer() gem: it runs after the response when the status is below 400, errors are reported and swallowed, and php artisan serve keeps the connection open so locally it can look like the refresh still blocks. Measure on PHP-FPM.
First visitor after a full expiry still waits. Flexible removes the stampede for everyone during the stale window. The one request that finds nothing in the cache still runs the callback inline. If that callback is 5 seconds, that one user waits 5 seconds. Pre-warm critical keys on deploy if that matters.
Locks need a lock-capable store. The refresh path calls $this->store->lock(...). Array cache in unit tests is fine for happy paths; in production stick to Redis, database, Memcached, file or another driver that implements locks if you expect concurrent stale hits.
Do not nest a 30-second callback inside every Inertia partial reload. Flexible helps, but a prop that is expensive and requested on every poll still burns workers during refresh. Pair it with Inertia once props or a longer fresh window when the number barely moves.
Tip: Start with [5, 30] and tune
Five seconds fresh and thirty seconds total is a solid default for dashboard aggregates. Bump fresh if the number changes slowly (daily stats). Shrink stale if “wrong for half a minute” would confuse people. Log callback duration once; if it is over a couple of seconds, consider a real queued rebuild instead.
Try it yourself#
Prove fresh / stale / expired in tinker
php artisan tinkeruse Illuminate\Support\Facades\Cache;
Cache::flush();
$calls = 0;
$fn = function () use (&$calls) {
$calls++;
usleep(80_000);
return "n-{$calls}";
};
Cache::flexible('demo', [1, 3], $fn); // miss, ~80 ms, n-1
Cache::flexible('demo', [1, 3], $fn); // fresh, ~0 ms, n-1
sleep(2);
Cache::flexible('demo', [1, 3], $fn); // stale, ~0 ms, still n-1; refresh deferred
app(\Illuminate\Support\Defer\DeferredCallbackCollection::class)->invoke();
Cache::get('demo'); // n-2
sleep(4);
Cache::flexible('demo', [1, 3], $fn); // expired, ~80 ms, n-3How useful is this?#
| Score | |
|---|---|
| Time saved per use | ★★★★☆ Swap remember for flexible, tune two numbers |
| How often you’ll reach for it | ★★★★☆ Every dashboard, public count, slow Inertia prop |
| Risk if misused | ★★★☆☆ Stale authz or money figures will bite you |
| Overall | 4.5 / 5: the default for “fast enough” cached props |
If you already Cache::remember a count or aggregate for an Inertia page, you can switch today.
Recap#
Cache::flexible($key, [$fresh, $stale], $callback)implements stale-while-revalidate on top of your cache store.- Fresh hits return immediately. Stale hits return immediately and refresh via
defer()after the response. Only a full miss makes the user wait. - Concurrent stale refreshes share a named deferred callback and a cache lock.
- Keep it for aggregates and public stats; keep exact data on
rememberor uncached queries. - It shipped in Laravel 11.23 and was tested here on Laravel 13.35.
FAQ#
Is Cache::flexible the same as Cache::remember?
No. remember always recalculates inline when the TTL is gone. flexible adds a stale window where the old value is served and the refresh runs after the response via defer().
Does the user who triggers the refresh see the new value?
Not on that request. They get the stale value; the deferred refresh writes the new value for the next visitors. That is the point of stale-while-revalidate.
Which Laravel version do I need?
Cache::flexible arrived in Laravel 11.23 (Laracon 2024 PR #52710). Everything here was tested on Laravel 13.35 with the database cache store.
How is this different from defer() alone?
defer() runs arbitrary work after the response. Cache::flexible uses defer() only for the stale refresh path, and still returns a value to the current request. See Laravel defer().
Can I use it with cache tags?
Yes, call flexible on a tagged repository (Cache::tags([...])->flexible(...)) on drivers that support tags. Invalidation still goes through your usual tags()->flush() or forget() flow.
Next steps#
- Laravel defer(): run code after the response, the helper flexible uses for refreshes
- Cache a prop and forget it on write, when plain
rememberis the right tool - Inertia partial reloads, so slow props are not refetching on every visit
- Laravel docs: Stale while revalidate



