Cache::flexible(): Stale-While-Revalidate Caching in Laravel
Most of us reach for Cache::remember() and call it a day. It works fine until the cache expires and the next unlucky request has to sit there while your app rebuilds an expensive value from scratch. Multiply that by a few concurrent requests and you've got a thundering herd hammering your database at the exact moment your cache goes cold.
Cache::flexible() fixes this with a stale-while-revalidate pattern: it serves the stale value immediately while quietly refreshing it in the background for the next request.
The basic remember() problem
$products = Cache::remember('featured-products', now()->addMinutes(10), function () {
return Product::where('is_featured', true)
->with('category')
->orderBy('sort_order')
->get();
});
Ten minutes and one second after this runs, the cache entry is gone. The next request pays the full cost of that query, synchronously, blocking the response. If that query takes 800ms, one unlucky visitor gets an 800ms page load for no reason other than bad timing.
Enter flexible()
$products = Cache::flexible('featured-products', [600, 1200], function () {
return Product::where('is_featured', true)
->with('category')
->orderBy('sort_order')
->get();
});
The array is [$freshSeconds, $staleSeconds]. For the first 600 seconds, the value is fresh and gets returned straight from cache, no callback invoked. Between 600 and 1200 seconds, the value is stale — Laravel still returns it immediately, but it dispatches the callback to regenerate the value in the background so the next caller gets a fresh copy. After 1200 seconds, there's nothing left to serve stale, so the request blocks and regenerates synchronously, same as remember() would.
In practice this means your visitors almost never feel a cache miss. They get old-but-recent data instantly while the expensive recompute happens off to the side.
Where it shines
flexible() is a great fit for things like:
- Homepage widgets and featured content blocks
- Third-party API responses (pricing feeds, exchange rates, inventory counts)
- Dashboard aggregates that are expensive to compute but tolerate a few minutes of staleness
It's a poor fit for anything that needs to be exactly correct right now — a user's current cart total, an account balance, an in-progress payment state. For those, keep using remember() or skip caching entirely.
A real example: exchange rates
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
class ExchangeRateService
{
public function rates(): array
{
return Cache::flexible('exchange-rates:usd', [300, 900], function () {
$response = Http::baseUrl('https://api.phparchitect.com')
->get('/v1/rates', ['base' => 'USD']);
return $response->json('rates', []);
});
}
}
Exchange rates from a third-party API don't need to be second-fresh. Five minutes of true freshness followed by ten minutes of "stale but being refreshed" is a perfectly reasonable trade-off, and it means your app never blocks on that outbound HTTP call during normal traffic.
One caveat
The background refresh happens on the request that discovers the stale value — it's dispatched after the response starts returning, similar to how terminating() callbacks work, not as a queued job. If your callback is genuinely slow (multi-second), that refresh still consumes a PHP worker for a bit, just not one your user is waiting on. For truly expensive rebuilds, you're often better off having a scheduled job populate the cache directly and using remember() with a long TTL as the read path.
Cache::flexible() won't replace every caching strategy you have, but it's a one-line upgrade for the "occasionally stale is fine, occasionally slow is not" data that's all over most Laravel apps.