A cache entry expiring under load means every request recomputes it at once, and a lock that expires before the computation finishes makes it worse.
// the lock TTL must exceed the work, or two holders exist
$lock = Cache::lock('report:monthly', seconds: 120);
if ($lock->get()) {
try {
$value = $this->compute(); // measured p99: 40s
Cache::put('report:monthly', $value, now()->addHour());
} finally {
$lock->release();
}
} else {
$value = Cache::get('report:monthly:stale'); // serve stale
}
Two things have to be true: the lock outlives the p99 of the work, and the losers have something to serve. Writing a second, longer-lived key holding the previous value is what makes the waiting path acceptable — without it the losers either block or return an error, and blocking under a stampede is how a pool of workers gets exhausted.