In Laravel, what do Cache::withoutOverlapping() and Cache::funnel() add over a raw Cache::lock(), and what are their defaults?
answer
- one runner versus N runners
- withoutOverlapping: waits 10 s, lockFor 0
- LockTimeoutException versus LimiterTimeoutException
- funnel: limit, releaseAfter 60, block 3
- funnel needs a LockProvider store
basics
~20 sCache::withoutOverlapping($key, $callback) wraps lock()->block(): it waits up to 10 seconds, runs the callback and releases, holding the lock with no lifetime by default. Cache::funnel($name)->limit(N) allows up to N concurrent runs, each slot a lock that expires after 60 seconds by default.
solid answer
~40 s`Cache::withoutOverlapping('statement:42:2026-08', $callback, lockFor: 0, waitFor: 10)` is shorthand for `Cache::lock($key, $lockFor)->block($waitFor, $callback)`: one runner at a time, a 10-second wait, `LockTimeoutException` if it cannot acquire, release in `finally`. Its default `lockFor` of 0 means the Redis lock has no expiry, so a killed process leaves it until `forceRelease()`; pass a lifetime. `Cache::funnel('pdf-renderer')->limit(3)->releaseAfter(60)->block(10)->then($ok, $failed)` is a concurrency limiter: it tries slots named `pdf-renderer1`…`pdf-renderer3`, each an ordinary lock, runs the callback in the first free slot and releases it. Defaults are a 60-second slot lifetime and a 3-second wait; `limit()` has none. Without a failure closure it throws `LimiterTimeoutException`, and on a store without locks `funnel()` throws `BadMethodCallException`.
code
php · 15 lines<?php
use Illuminate\Cache\Limiters\LimiterTimeoutException;
use Illuminate\Support\Facades\Cache;
try {
Cache::store('redis')->funnel('pdf-renderer')
->limit(3)
->releaseAfter(120)
->block(10)
->then(fn () => $renderer->render($statement));
} catch (LimiterTimeoutException $e) {
// All three slots stayed busy for 10 seconds.
$this->release(30);
}go deeper
Recall that withoutOverlapping allows one run at a time and funnel allows up to a set number, both built on cache locks.
Explain that withoutOverlapping is lock()->block() with a 10-second wait, and how funnel's limit, releaseAfter and block interact.
Set explicit lock lifetimes, pick skip versus wait deliberately, size funnel slots against real work time, and handle LimiterTimeoutException by re-queuing.
Decide where concurrency limits belong for shared downstream services, and how per-app funnels interact when several apps call the same service.
## Two helpers built on the same locks Both methods live on the cache repository and use the store's ordinary atomic locks. They exist so you do not hand-write the acquire, wait, run and release sequence. - **`withoutOverlapping`** answers "only **one** of these may run at a time". - **`funnel`** answers "at most **N** of these may run at a time". ## `Cache::withoutOverlapping()` The signature is `withoutOverlapping($key, callable $callback, $lockFor = 0, $waitFor = 10, $owner = null)`, and the body is a single line: `lock($key, $lockFor, $owner)->block($waitFor, $callback)`. ```php $pdf = Cache::withoutOverlapping( "statement:{$account->id}:{$month}", fn () => $generator->generate($account, $month), lockFor: 300, waitFor: 5, ); ``` What follows from that line: 1. The caller **waits** up to `waitFor` seconds (10 by default), retrying every 250 ms. 2. If the lock never frees, `Illuminate\Contracts\Cache\LockTimeoutException` is thrown. 3. Once acquired, the callback runs and the lock is released in `finally`; the callback's return value is returned. 4. With the default `lockFor: 0` the lock has **no lifetime of its own**. On Redis it never expires; on the database store it falls back to `lock_timeout` (86400 s). A worker killed mid-callback never reaches `finally`, so the statement stays locked until someone calls `forceRelease()`. Passing a realistic `lockFor` is the safer habit. For duplicate statement jobs, remember that the second caller **waits and then runs** — it does not skip. If the second run should be skipped, use `Cache::lock(...)->get($callback)` instead. This is the cache-repository method. The scheduler's `withoutOverlapping()` on scheduled tasks and the `WithoutOverlapping` job middleware are different features with their own options. ## `Cache::funnel()` Suppose statement PDFs are rendered by an internal service that tolerates three concurrent requests. A funnel caps that: ```php Cache::funnel('pdf-renderer') ->limit(3) ->releaseAfter(120) ->block(10) ->then( fn () => $renderer->render($statement), fn ($e) => $this->release(30), // re-queue the job ); ``` | Builder method | Meaning | Default | |---|---|---| | `limit($n)` | maximum concurrent runs | none — you must set it | | `releaseAfter($s)` | lifetime of each slot's lock, a crash safety net | 60 | | `block($s)` | how long to wait for a free slot | 3 | | `sleep($ms)` | pause between slot scans | 250 | | `then($ok, $failed)` | run `$ok` in a slot; call `$failed($exception)` on timeout | — | Mechanically, the limiter loops over slot names `pdf-renderer1` … `pdf-rendererN`, trying to acquire each as a normal lock with `releaseAfter` as its lifetime. The first acquired slot runs the callback and is released afterwards, including when the callback throws. If no slot frees within `block` seconds it throws `Illuminate\Cache\Limiters\LimiterTimeoutException`, or passes it to the failure closure when one is given. `funnel()` checks that the store implements `Illuminate\Contracts\Cache\LockProvider` and throws `BadMethodCallException` otherwise. Call it on a specific store with `Cache::store('redis')->funnel(...)`. ## Choosing among the three | Need | Tool | |---|---| | Skip if someone is already doing it | `Cache::lock($k, $s)->get($cb)` | | Wait briefly, then do it yourself | `Cache::withoutOverlapping($k, $cb, lockFor: $s)` | | Allow up to N at once | `Cache::funnel($name)->limit(N)` | | Release from another process | `Cache::lock()` with `owner()` and `restoreLock()` | ## Pitfalls - A `releaseAfter` shorter than the real work lets an extra caller into the funnel. - `block(3)` is short; a queued job that fails the funnel should usually be released back to the queue rather than failed. - Both helpers share the store requirements of plain locks: every worker must use the same central store. ## Return values - `withoutOverlapping()` returns whatever the callback returns, because `block()` with a callback returns the callback's result. - `funnel()->then($ok)` returns `$ok`'s result when a slot was acquired. - `funnel()->then($ok, $failed)` returns `$failed`'s result on timeout, so a job can, for example, return early after releasing itself back to the queue. Knowing these makes it easy to compose the helpers inside services without extra flags.
- In Laravel, why can a crashed worker leave a Cache::withoutOverlapping() key locked for a long time?`withoutOverlapping()` defaults `lockFor` to 0. On Redis that creates a lock with no expiry, and on the database store it falls back to `lock_timeout`, 86400 seconds. Release happens in a `finally` block, which a killed process never reaches. Pass an explicit `lockFor` sized to the work, or clear it with `forceRelease()`.
- In Laravel, what happens with Cache::funnel('pdf-renderer')->then($callback) when limit() was never called?The limiter has no slot count, so its loop over slots `1..N` runs zero times and never acquires anything. After the default 3-second wait it throws `LimiterTimeoutException`, or calls the failure closure. `limit()` has no default and must be set.
saying these in an interview costs you the question
- Cache::withoutOverlapping() skips the callback when the lock is busy
- withoutOverlapping() locks for 10 seconds by default
- Cache::funnel() works on stores without lock support
- funnel() throws LockTimeoutException when every slot is busy
- Cache::withoutOverlapping() is the same feature as the scheduler's withoutOverlapping()