A lock around a recomputation, and the requests that wait

The simpler stampede fix is a lock, and it converts a burst of computation into a burst of waiting.

$lock = Cache::lock("recompute:{$key}", 10);

if ($lock->get()) {
    try { return $this->recomputeAndStore($key); }
    finally { $lock->release(); }
}

// and the decision for everybody else:
return $this->stale($key)          // serve stale, if there is one
    ?? $this->waitFor($lock, 2)    // or wait, briefly
    ?? throw new Unavailable();     // or fail fast

Serving stale is nearly always the right branch and requires keeping the old value past its logical expiry, which means two TTLs per entry. Waiting is acceptable for a short computation and is a queue of held connections for a long one — which turns a cache miss into a capacity problem, and is the failure this was meant to prevent.