skip to content

In Laravel, what do Cache::withoutOverlapping() and Cache::funnel() add over a raw Cache::lock(), and what are their defaults?

level: middleimportance: nice to knowfreq 20%

answer

  1. one runner versus N runners
  2. withoutOverlapping: waits 10 s, lockFor 0
  3. LockTimeoutException versus LimiterTimeoutException
  4. funnel: limit, releaseAfter 60, block 3
  5. funnel needs a LockProvider store

basics

~20 s

Cache::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
<?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

for a junior

Recall that withoutOverlapping allows one run at a time and funnel allows up to a set number, both built on cache locks.

for a middle

Explain that withoutOverlapping is lock()->block() with a 10-second wait, and how funnel's limit, releaseAfter and block interact.

for a senior

Set explicit lock lifetimes, pick skip versus wait deliberately, size funnel slots against real work time, and handle LimiterTimeoutException by re-queuing.

for a principal

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()