mohamedtarek / laravel-stampede-guard
Cache stampede prevention for Laravel: atomic-lock remember and XFetch probabilistic early expiration.
Package info
github.com/MohamedTarek/laravel-stampede-guard
pkg:composer/mohamedtarek/laravel-stampede-guard
Requires
- php: ^8.2
- illuminate/bus: ^11.0|^12.0|^13.0
- illuminate/cache: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- laravel/serializable-closure: ^1.3|^2.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.6|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Stampede Guard
Stop cache stampedes in Laravel: one worker recomputes, everyone else keeps getting a value.
Two macros on Laravel's cache repository. Cache::rememberWithLock() puts an atomic lock
around the recompute so a cold key is computed exactly once. Cache::rememberXFetch()
refreshes a hot key before it expires, probabilistically and in the background, so no
reader ever sees a miss after the first fill. Works from Laravel 6 to Laravel 13.
The problem
A cache stampede (also called a thundering herd or dogpile) happens when a cached value
expires and every concurrent request reads a miss at the same instant. Each one recomputes
the same expensive value and hits the database at the same moment. Plain Cache::remember()
has no protection against this.
Background and the motivating incident are described in "Backend Architecture: Laravel Cache Stampede Prevention" (Smart Tech Devs Engineering).
Measured, not claimed
tests/Concurrency/StampedeTest.php forks 50 worker processes against Redis 7 that all hit
the same cold key at once, with a callback that sleeps 0.5 seconds before incrementing a
counter. Results from a real run:
| Strategy | Callback calls for 50 concurrent workers |
|---|---|
Cache::remember (plain, no protection) |
50 |
rememberWithLock |
1 |
rememberXFetch (cold start) |
1 |
The same suite runs on every CI leg: 97 tests across Laravel 6 to 13, unit tests on the array store, integration tests on Redis, and the fork proof above.
Two strategies
rememberWithLock |
rememberXFetch |
|
|---|---|---|
| Mechanism | Blocking mutex with double-checked reads | Probabilistic early refresh (XFetch) |
| Cold key | One worker computes, the rest wait for the lock | Same mutex as rememberWithLock |
| Warm key near expiry | Everyone gets the value until it expires, then one recompute | One reader refreshes before expiry; readers never wait |
| Latency for readers | The waiters block during the recompute | Always the cached value (queue mode) or fresh (inline mode) |
| Needs a queue | No | Optional (default queue, can run inline) |
| Reach for it when | The value is cheap enough that a short wait is fine, or you cannot run a queue | The value is slow, the key is hot, and a stale-for-seconds value is acceptable |
How it compares to Laravel's own tools
Cache::remember |
Cache::flexible (Laravel 11.23+) |
rememberWithLock |
rememberXFetch |
|
|---|---|---|---|---|
| Cold key: workers computing | All of them (stampede) | All of them (stampede) | One; others wait for the lock | One, via the same mutex as rememberWithLock |
| Warm key near expiry | Stampede at the moment of expiry | One deterministic refresh, deferred until after the response | Same as a cold key: one recompute per expiry, others wait on the lock | Probabilistic refresh before expiry; readers keep getting the still-valid value |
| Needs a queue | No | No (uses defer(), not a queue) |
No | Optional (default: queue; can run inline) |
| Min Laravel | Any | 11.23 | 6 | 6 |
Installation
composer require mohamedtarek/laravel-stampede-guard
Composer picks the release line that matches the illuminate/cache version already
required by your application (see Compatibility). Publish the config file
if you want to change the defaults:
php artisan vendor:publish --tag=stampede-config
This writes config/stampede.php. Every option can also be passed per call.
Usage
rememberWithLock
remember() guarded by an atomic lock with double-checked reads, so a cold key is computed
by exactly one worker while every other worker waits for the result instead of recomputing it.
use Illuminate\Support\Facades\Cache; $value = Cache::rememberWithLock('report:2026-09', 3600, function () { return Report::expensive(); });
The macro is added to Illuminate\Cache\Repository, so it is available on
Cache::store('redis'), Cache::store('memcached'), and so on, not only the default
Cache facade.
public function rememberWithLock(string $key, $ttl, Closure $callback, array $options = []): mixed
Options (merged from config('stampede.prefix') and config('stampede.lock'), then
per-call overrides):
| Option | Default | Meaning |
|---|---|---|
lock_seconds |
10 |
Seconds the computing worker may hold the mutex before it expires on its own. |
wait_seconds |
5 |
Seconds other workers block waiting for the mutex before giving up. |
on_timeout |
'throw' |
What a waiter does on timeout: 'throw' (LockTimeoutException) or 'compute' (run the callback unlocked). |
prefix |
'stampede' |
Namespace for the lock key: "{prefix}:lock:{key}". |
// Wait at most one second, then compute without the lock rather than fail. $value = Cache::rememberWithLock('report:2026-09', 3600, fn () => Report::expensive(), [ 'wait_seconds' => 1, 'on_timeout' => 'compute', ]);
If the underlying cache store does not support atomic locks, the call throws
MohamedTarek\Stampede\Exceptions\UnsupportedStoreException before any work is done.
A callback that returns null throws InvalidArgumentException. Laravel's own
remember() has no such check: it silently stores nothing useful and recomputes the
callback on every call.
rememberXFetch
Probabilistic early expiration (the XFetch algorithm). A cached value is served until its logical expiry; as that approaches, one reader "volunteers" (the probability of volunteering rises with how long the value took to compute) and refreshes it ahead of time, so no other reader ever sees a cold key after the first fill.
use Illuminate\Support\Facades\Cache; $value = Cache::rememberXFetch('report:2026-09', 3600, function () { return Report::expensive(); });
public function rememberXFetch(string $key, $ttl, callable $callback, array $options = []): mixed
Options (merged from config('stampede.prefix'), config('stampede.lock') and
config('stampede.xfetch'), then per-call overrides):
| Option | Default | Meaning |
|---|---|---|
beta |
1.0 |
XFetch tuning: values above 1 make workers volunteer earlier, values below 1 make them volunteer later. |
grace_seconds |
300 |
Physical TTL is ttl + grace_seconds. Stale data may be served for this long if refreshes keep failing. |
refresh |
'queue' |
How the volunteer refreshes: 'queue' (dispatches RefreshCacheJob) or 'inline' (in the volunteer's own request). |
refresh_lock_seconds |
60 |
Seconds the "someone is refreshing" marker lives; it auto-expires if the job dies. |
queue.connection |
null |
Queue connection RefreshCacheJob is dispatched on. null uses the framework default. |
queue.queue |
null |
Queue name RefreshCacheJob is dispatched on. null uses the framework default. |
store |
null |
Cache store name the refresh job reuses. null auto-detects the store the call was made on. |
// Refresh in the request itself and pay the compute cost there, no queue involved. $value = Cache::rememberXFetch('report:2026-09', 3600, fn () => Report::expensive(), [ 'refresh' => 'inline', 'grace_seconds' => 60, ]); // Route the background refresh to a dedicated queue. $value = Cache::rememberXFetch('report:2026-09', 3600, fn () => Report::expensive(), [ 'queue' => ['connection' => 'redis', 'queue' => 'cache-refresh'], ]);
How the decision is made
Behind the caller's key, the package stores an envelope instead of the raw value:
['v' => $value, 'e' => $logicalExpiryUnixTimestamp, 'd' => $computeSeconds]
v is the cached value, e is when the value logically expires, and d is how long the
callback took to compute it the last time it ran. Every read decides whether to volunteer
for an early refresh with:
now - d * beta * ln(rand) >= expiry
where rand is drawn fresh on each call from (0, 1]. beta scales how aggressively the
package refreshes ahead of expiry: raising it makes early refreshes more likely (and
earlier), lowering it makes the package wait closer to the real expiry before anyone
volunteers.
A key that has never been cached (a cold start) has no envelope to read a compute time
from, so rememberXFetch falls back to the same mutex used by rememberWithLock for the
first fill, then switches to the probabilistic behaviour above once an envelope exists.
Things to know
- A real TTL is required. A
nullTTL or a TTL that resolves to zero or fewer seconds throwsInvalidArgumentException, since a value cached forever has no logical expiry to refresh ahead of. - Read the key only through
rememberXFetch. The key holds the envelope above, not your value. A plainCache::get()returns the envelope array, and mixingCache::remember()andrememberXFetch()on the same key makes them overwrite each other's format on every call. To move an existing key torememberXFetch, use a new key name. - Tagged caches are not supported. Calling either macro on
Cache::tags([...])throwsInvalidArgumentException. - Unnamed repositories need the
storeoption in queue mode. If the repository was not resolved by name through the cache manager (Cache::repository($store),Cache::build([...]),Cache::memo(), a hand-builtnew Repository(...)), the store name cannot be detected. In queue mode that throwsInvalidArgumentExceptionat call time, because the job would otherwise refresh the default store; pass thestoreoption or userefresh => 'inline'. - Closures are serialized in queue mode. Capture ids rather than models, connections, or other non-serializable values in the callback, the same rule as any other queued job in Laravel.
Queue vs inline refresh
- Queue mode (
refresh => 'queue', the default): the volunteering request returns the still-cached (stale) value immediately and dispatchesRefreshCacheJobto recompute in the background. - Inline mode (
refresh => 'inline'): the volunteering request itself recomputes the value synchronously and returns the freshly computed value, not the stale one.
On the sync queue connection, RefreshCacheJob runs inside the dispatching request
rather than on a worker, so EarlyRefreshCompleted fires before EarlyRefreshScheduled
for that request.
When an early refresh fails, readers keep getting the stale value instead of an exception:
- If the queue dispatch fails (for example, the queue backend is down) or an inline refresh
throws, the volunteering request fires
EarlyRefreshFailed, reports the exception through Laravel's exception handler, and returns the stale value. It keeps the refresh lock, so the next attempt happens only afterrefresh_lock_seconds: at most one attempt per window. - A queued
RefreshCacheJobthat throws on the worker firesEarlyRefreshFailed, releases the refresh lock and rethrows, so the queue's retries andfailed_jobsapply as for any other job.
Either way the stale value is served until grace_seconds runs out. After that the key is
physically gone, and the next read takes the cold path (the mutex), where the callback's
exception propagates as usual.
Events
All events are plain classes carrying string $key and ?string $store.
| Event | Extra payload | Fired when |
|---|---|---|
LockAcquired |
float $waitedSeconds |
The mutex was acquired (cold path of either strategy). |
LockWaited |
float $waitedSeconds |
The mutex was acquired after waiting more than 0 seconds. |
EarlyRefreshScheduled |
string $mode ('queue'/'inline') |
A volunteer won the refresh lock (in queue mode: after the job was dispatched). |
EarlyRefreshCompleted |
string $mode, float $computeSeconds |
The envelope was rewritten with a fresh value. |
EarlyRefreshFailed |
string $mode, Throwable $exception |
The refresh callback threw, or the refresh job could not be dispatched. |
use Illuminate\Support\Facades\Event; use MohamedTarek\Stampede\Events\EarlyRefreshFailed; Event::listen(function (EarlyRefreshFailed $event) { Log::warning('Stampede refresh failed', [ 'key' => $event->key, 'store' => $event->store, 'mode' => $event->mode, 'exception' => $event->exception, ]); });
Store support
Atomic locks (Illuminate\Contracts\Cache\LockProvider) are required by both strategies.
Support by store and Laravel version:
| Store | Supported since |
|---|---|
redis |
Every supported Laravel version |
memcached |
Every supported Laravel version |
dynamodb |
Every supported Laravel version |
array |
Every supported Laravel version |
database |
Laravel 7 |
file |
Laravel 8 |
Any other store, or database/file on an older Laravel version than the one listed,
throws MohamedTarek\Stampede\Exceptions\UnsupportedStoreException naming the store class
and, where relevant, the Laravel version that added lock support to it.
Advanced configuration
The published config/stampede.php, with its defaults:
<?php return [ // Namespace for the package's lock keys: "{prefix}:lock:{key}" for the // mutex and "{prefix}:refresh:{key}" for the XFetch refresh marker. 'prefix' => 'stampede', 'lock' => [ // Seconds the computing worker may hold the mutex before it expires on its own. 'lock_seconds' => 10, // Seconds other workers block waiting for the mutex before giving up. 'wait_seconds' => 5, // What waiters do on timeout: 'throw' (LockTimeoutException) or 'compute' (run the callback unlocked). 'on_timeout' => 'throw', ], 'xfetch' => [ // XFetch tuning: values above 1 refresh earlier, values below 1 refresh later. 'beta' => 1.0, // Physical TTL = ttl + grace_seconds. Stale data may be served for this long if refreshes keep failing. 'grace_seconds' => 300, // How the volunteer refreshes: 'queue' (dispatches RefreshCacheJob) or 'inline' (in its own request). 'refresh' => 'queue', // Seconds the "someone is refreshing" marker lives; it auto-expires if the job dies. 'refresh_lock_seconds' => 60, // Queue routing for RefreshCacheJob. null means the framework defaults. 'queue' => [ 'connection' => null, 'queue' => null, ], ], ];
How the values combine:
prefixand thelockblock apply torememberWithLock(). Thexfetchblock applies torememberXFetch(), which also uses thelockblock for its cold-start mutex.- Every key can be overridden per call through the
$optionsarray of either macro, using the same names without thelock.orxfetch.prefix, for example['wait_seconds' => 0, 'on_timeout' => 'compute']or['refresh' => 'inline', 'grace_seconds' => 60]. Thequeueoverride is merged per key, so['queue' => ['queue' => 'cache']]keeps the default connection. rememberXFetch()accepts one extra per-call key,store, which names the cache store the refresh job should reuse when it cannot be detected. It has no config default.- Unknown keys, wrong types (
'5'instead of5) and values outside the allowed set (on_timeout,refresh) throwInvalidArgumentExceptionat call time. - Config is read on every call, so
config(['stampede.xfetch.refresh' => 'inline'])in a test takes effect immediately.
A typical production setup pins the refresh job to its own queue so a slow recompute never delays user-facing jobs:
'xfetch' => [ 'refresh' => 'queue', 'queue' => [ 'connection' => 'redis', 'queue' => 'cache-refresh', ], ],
and runs php artisan queue:work redis --queue=cache-refresh,default.
Compatibility
| Branch | Laravel | PHP |
|---|---|---|
3.x (main) |
11, 12, 13 | 8.2 to 8.5 |
2.x |
8, 9, 10 | 7.3 to 8.3 |
1.x |
6, 7 | 7.2.5 to 8.0 (Laravel 6 and 7 do not run on PHP 8.1+) |
composer require mohamedtarek/laravel-stampede-guard resolves the right line from the
illuminate/cache version already installed in your application. The API, the tests and
this README are identical on every line; only the PHP syntax of the source differs.
License
MIT. See LICENSE.md. Copyright (c) 2026 Mohamed Tarek.