ShouldBeUnique vs WithoutOverlapping: Neither Sets a Lock Expiry for You
Here's a key that was sitting in my Redis a few minutes ago:
$ redis-cli --scan --pattern "*overlap*"
laravel-database-laravel-cache-laravel-queue-overlap:App\Jobs\SyncFeed:feed
$ redis-cli TTL "laravel-database-laravel-cache-laravel-queue-overlap:App\Jobs\SyncFeed:feed"
-1
A TTL of -1 is Redis for "this key exists and nothing will expire it". Its owner is a worker process I killed a minute earlier. The job it was protecting never finished and never will: it's in failed_jobs with a MaxAttemptsExceededException. Every later dispatch of SyncFeed fails the same way.
Laravel calls four different things a lock, and ShouldBeUnique and WithoutOverlapping are two of them. This post puts all four side by side in a scratch app on Laravel 13.32.0, PHP 8.5, with Redis 8 and the database store on SQLite: the key each one writes, the default expiry, where the lock is taken, where it's given back, and what can clear it when something goes wrong.
The four locks on one page
| Lock | Key it writes | Default expiry | Taken when | Released when | Cleared by |
|---|---|---|---|---|---|
withoutOverlapping() on a scheduled task |
framework/schedule-<sha1> |
1,440 minutes | schedule:run decides the task should run |
the task finishes, or on a termination signal with pcntl loaded |
schedule:clear-cache, which touches scheduler mutexes only |
WithoutOverlapping job middleware |
laravel-queue-overlap:<job>:<key> |
none | the worker enters the middleware | the middleware's finally block |
cache:clear --locks, which flushes the whole lock store |
ShouldBeUnique |
laravel_unique_job:<job>:<uniqueId> |
none | the dispatch() statement ends |
the job completes, or fails for the last time | cache:clear --locks, same blunt instrument |
Cache::lock() |
the name you pass in | whatever you pass in | get() or block() |
release(), forceRelease(), or expiry |
your code |
"None" needs a footnote, because it means two different things. Both queue locks ask the cache for a lock of 0 seconds. On Redis, RedisLock::acquire() falls back to setnx, which writes no TTL at all. On the database store, DatabaseLock substitutes its own $defaultTimeoutInSeconds, which is 86,400. The same job class gives you a lock that clears itself in a day on the database store, and one that sits there until something flushes it on Redis.
If you came here only to choose between the two: ShouldBeUnique when a duplicate dispatch is work you want thrown away at the door, WithoutOverlapping when the second job has to happen but not yet. Both need an expiry you set yourself.
One note on the keys. The table shows what the framework builds; what you see in redis-cli has the Redis connection prefix (laravel-database-) and the cache prefix (laravel-cache-) stacked in front, and the cache_locks table shows only the second. My Redis 8 is a container, so every redis-cli line below really ran as docker exec lockredis redis-cli.
The scheduler's lock is the only one with a deadline in its signature
withoutOverlapping() on a scheduled task takes a mutex before the task runs and drops it afterwards. Its signature carries the expiry, in minutes:
public function withoutOverlapping($expiresAt = 1440, $releaseOnTerminationSignals = true)
{
$this->withoutOverlapping = true;
$this->expiresAt = $expiresAt;
$this->releaseOnTerminationSignals = $releaseOnTerminationSignals;
return $this->skip(function () {
return $this->mutex->exists($this);
});
}
1,440 minutes is 24 hours, and CacheEventMutex multiplies it by 60 on the way into the cache. The scheduling docs say the same, and this is the only one of the four whose default the documentation states at all.
I scheduled a command that sleeps for two minutes, ran schedule:run, and looked in the store:
$ redis-cli --scan --pattern "*schedule*"
laravel-database-laravel-cache-framework\schedule-476c6850accd689ca614e8f53dcdf4dbaebb152f
$ redis-cli TTL "laravel-database-laravel-cache-framework\schedule-476c6850accd689ca614e8f53dcdf4dbaebb152f"
86394
86,394 seconds, six short of the full 86,400 because the probe ran after the acquire. Event::mutexName() builds that key with DIRECTORY_SEPARATOR, so the separator is a backslash on Windows and a forward slash everywhere else, and the hash covers the cron expression plus the normalised command string.
Then I killed the process with taskkill /F. The mutex stayed, at 86391, so that task isn't running again today. $releaseOnTerminationSignals defaults to true, but it handles SIGTERM, SIGINT and SIGQUIT only, it needs the pcntl extension, and it skips anything marked runInBackground(). I work on Windows, where pcntl doesn't exist.
This is what makes the scheduler lock survivable:
$ php artisan schedule:clear-cache
INFO Deleting mutex for ["php" "artisan" lock:sleeper 120].
The key was gone straight after. ScheduleClearCacheCommand walks your schedule and forgets each mutex it finds held, so it can reach every scheduled task, and nothing else: not a queue lock, not a Cache::lock() key. (Your output has your PHP binary's full path where I've put php.) Post 17 covers how to pick the number you pass.
A job that times out leaves the queue lock behind
The queue middleware shares a name with the scheduler method and almost nothing else. This is the whole mechanism:
$lock = Container::getInstance()->make(Cache::class)->lock(
$this->getLockKey($job), $this->expiresAfter
);
if ($lock->get()) {
try {
$next($job);
} finally {
$lock->release();
}
} elseif (! is_null($this->releaseAfter)) {
$job->release($this->releaseAfter);
}
$expiresAfter is 0 unless you call expireAfter(), and $releaseAfter is 0 unless you call releaseAfter(). That finally covers an exception thrown inside handle(), and the two ordinary ways a worker stops mid-job both skip it.
The first is the job timeout. Worker::registerTimeoutHandler() arms pcntl_alarm(), and the SIGALRM handler ends in Worker::kill(), which is posix_kill(getmypid(), SIGKILL) followed by exit($status). A finally block does not run after exit():
php -r 'try { echo "in\n"; exit(0); } finally { echo "FINALLY RAN\n"; }'
That prints in and stops. So the docs sentence about a job that "may unexpectedly fail or timeout in such a way that the lock is not released" is describing the default path, not an edge case. The second way is a worker killed from outside, which is what I could reproduce here: supportsAsyncSignals() is extension_loaded('pcntl'), so on Windows the timeout handler never arms in the first place.
I gave SyncFeed a 30-second sleep() and the middleware with no expiry, dispatched one, started php artisan queue:work --tries=3 --sleep=1, and killed it with taskkill /F eight seconds in. I'd set DB_QUEUE_RETRY_AFTER=10 to make the retry window short enough to watch. Here's what a fresh worker did with the job:
2026-09-20 17:32:51 App\Jobs\SyncFeed .. RUNNING
2026-09-20 17:32:51 App\Jobs\SyncFeed .. 26.65ms DONE
2026-09-20 17:32:51 App\Jobs\SyncFeed .. RUNNING
2026-09-20 17:32:51 App\Jobs\SyncFeed .. 4.75ms DONE
2026-09-20 17:32:51 App\Jobs\SyncFeed .. RUNNING
2026-09-20 17:32:51 App\Jobs\SyncFeed .. 4.67ms FAIL
Look at the durations. The job sleeps for thirty seconds, and two attempts were reported DONE in 26.65 ms and 4.75 ms. Nothing ran. The middleware couldn't take the lock, released the job back with releaseAfter at 0 so it returned instantly, and the worker logged each bounce as a finished attempt. Three of them inside the same second, --tries=3 spent, MaxAttemptsExceededException.
The lock was still there afterwards, TTL -1, owned by the process I killed. That's the key from the top of this post. Issue #37060 landed on this back in 2021: the reporter couldn't say whether their jobs failed by exception or by timeout, the middleware's author guessed timeout, and the advice was to set a lock timeout equal to the job timeout. It was closed two days later, and the behaviour hasn't changed since. The queue docs point at expireAfter without saying that the default is no expiry at all.
ShouldBeUnique takes its lock before the job reaches the queue
This is the difference that decides which failures each one has. ShouldBeUnique doesn't lock in the worker. It locks in PendingDispatch::shouldDispatch(), called from that object's __destruct(), so the lock is taken when the pending dispatch goes out of scope, normally at the end of the dispatch() statement and always before the job is written to the queue. The lock comes off in CallQueuedHandler after the job completes or after its final failure.
Once the job is on the queue, the lock comes back when the job runs, or when a worker picks it up and finds its model deleted, and not otherwise. So a job removed any other way strands it, and the everyday route is queue:clear:
$ php artisan tinker --execute="App\Jobs\BuildReport::dispatch();"
$ php artisan tinker --execute="dump(DB::table('jobs')->count(), DB::table('cache_locks')->pluck('key')->all());"
1
array:1 [
0 => "laravel-cache-laravel_unique_job:App\Jobs\BuildReport:"
]
$ php artisan queue:clear --force
INFO Cleared 1 job from the [default] queue.
$ php artisan tinker --execute="dump(DB::table('jobs')->count(), DB::table('cache_locks')->pluck('key')->all());"
0
array:1 [
0 => "laravel-cache-laravel_unique_job:App\Jobs\BuildReport:"
]
$ php artisan tinker --execute="App\Jobs\BuildReport::dispatch(); dump(DB::table('jobs')->count());"
0
That's a scratch app, and --force skips the confirmation prompt you would get in production. The queue is empty, the lock row is not, and the dispatch after that put nothing on the queue: Queue\Console\ClearCommand calls clear() on the queue connection and touches nothing else. On the database store that's 24 hours of a job you can't dispatch.
cache:clear doesn't reach it on either store. On Redis the store's connection is cache and its lock_connection is default, which are two different Redis databases, and cache:clear flushes the first. On the database store the reason is plainer: DatabaseStore::flush() empties the cache table, and locks live in cache_locks.
What does clear it is cache:clear --locks, added in Laravel 13 by #58907:
$ php artisan cache:clear --locks
INFO Application cache locks cleared successfully.
$ php artisan tinker --execute="App\Jobs\BuildReport::dispatch(); dump(DB::table('jobs')->count());"
1
The job dispatches again, so the lock really is gone. Read the store's flushLocks() before you run that command on anything you care about, though: on Redis it's flushdb() on the lock connection, and on the database store it's an unfiltered delete() on cache_locks. Every scheduler mutex and every Cache::lock() the application is holding goes with it. On Redis it's worse than that: lock_connection defaults to default, and so does the redis queue connection in config/queue.php, so on stock config that flushdb() can delete your queued, reserved and delayed jobs as well. No command clears one queue lock on its own.
A stranded unique lock is silent, and that part is fixable. Since #61039, in v13.25.0, a dropped dispatch raises an event:
Event::listen(function (UniqueJobSkipped $event) {
Log::warning('unique job skipped', ['job' => get_class($event->job)]);
});
Illuminate\Queue\Events\UniqueJobSkipped carries the job instance and nothing else, it isn't in the queue documentation yet, and it turns a silent drop into a log line.
The model-deleted case is one of two paths that give the lock back without the job running, and it lives in CallQueuedHandler::handleModelNotFound(). The other is a rolled-back afterCommit() dispatch, which releases it through a callback registered in Queue::enqueueUsing(). I ran that one in tinker with existence checks on the key, which the block leaves out:
try {
DB::transaction(function () {
App\Jobs\BuildReport::dispatch()->afterCommit();
throw new RuntimeException('rollback');
});
} catch (RuntimeException) {
//
}
DB::transaction() rolls back and rethrows, hence the catch. The checks read the key directly, so they can't acquire the lock they are measuring:
["lock held inside the transaction", true]
["lock held after the rollback", false]
["jobs on the queue", 0]
The model-deleted path came from #54000 and the rollback path from #55420 and #61234, with #60906 for retries: four patches to this lock's release paths since January 2025, and none of them gave it a default expiry. If afterCommit is unfamiliar ground, I've covered every way to control it separately.
With Cache::lock, the expiry is yours
The fourth is the primitive the other three are built on, and the only one where the numbers are yours:
$lock = Cache::lock('import', 30);
if ($lock->get()) {
// ...
}
A second process asking for import gets false back, and so does release() from anything but the owner, which is why a stranded lock survives a well-behaved caller. forceRelease() ignores ownership and deletes it, and it's the only way to clear one stranded queue lock without touching the others:
$ php artisan tinker
>>> Cache::store('redis')->lock('laravel-queue-overlap:App\Jobs\SyncFeed:feed')->forceRelease();
= null
$ redis-cli --scan --pattern "*overlap*"
(nothing: the key is gone)
Name the store, because Cache::lock() uses whatever cache.default is, and look afterwards, because forceRelease() returns null whether or not there was a key to delete. That's what cleared the key this post opened with. One caveat on the key: queued listeners, mailables, notifications and queued closures define displayName(), and for those the framework puts an xxh128 hash of that name in the key where the class name would otherwise go. Read the real key out of the store rather than building it by hand.
Sort them by how they lose a lock instead
The queue docs separate these two by when they block: ShouldBeUnique stops a job being queued, WithoutOverlapping lets it queue and stops it running. Every comparison I could read sorts them the same way, and it's the right axis while everything works.
Sort them by how they lose a lock instead, and they trade places depending on what went wrong:
| Worker killed or job timed out | Job removed from the queue | |
|---|---|---|
WithoutOverlapping |
stranded: the finally never runs |
can't happen: the lock exists only while a worker holds it |
ShouldBeUnique |
recovers: the job is still reserved, gets retried, and releases the lock when it finishes | stranded: the job is gone, and nothing is left to release the lock |
I measured the cell that isn't obvious. Killing a worker on a BuildReport that sleeps for twenty seconds left the unique lock at -1 and the job reserved. Once retry_after expired a worker picked it up, ran it through to 20s DONE, and the key was gone. That recovery only needs a worker to come back.
What I'd actually set
For anything using the queue middleware, pass an expiry, and make it longer than your worst case:
public function middleware(): array
{
return [(new WithoutOverlapping($this->order->id))->expireAfter(180)];
}
For ShouldBeUnique the equivalent is the UniqueFor attribute, new in Laravel 13, though a public $uniqueFor property still works:
use App\Models\Product;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\UniqueFor;
#[UniqueFor(3600)]
class RebuildSearchIndex implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Product $product) {}
public function uniqueId(): string
{
return $this->product->id;
}
}
I put both settings on a test job, dispatched it, and read the keys back: 3600 on the unique lock, and 173 on the overlap lock seven seconds after the worker took it. Two numbers instead of two -1s.
Reach for Cache::lock() when the thing you're protecting isn't a job, or when you want block() to wait instead of releasing and retrying.
Getting the expiry wrong costs you in both directions, and they aren't symmetrical. Too short and two instances run at once, which is what happens when uniqueFor is too low. Too long, or missing, and a stranded ShouldBeUnique lock stops the job quietly, with nothing in the logs, while a stranded overlap lock is loud: every attempt fails. I'd rather debug either of those than duplicate work.
Worth five minutes right now: point redis-cli at your production cache and run --scan --pattern "*laravel_unique_job*" and --scan --pattern "*laravel-queue-overlap*", or select * from cache_locks on the database store. Anything with a TTL of -1 next to a job that isn't currently running is stranded. And if your deploys kill workers mid-job, post 12 covers that from the other end.
Everything I measured here was on one Windows machine against Redis 8 and SQLite, and the timeout path is read from the worker's source rather than run, because pcntl isn't there to arm it. I'd like to know whether the Memcached and DynamoDB stores treat a zero-second lock the same way, and whether anyone's pcntl path has ever saved a scheduled task in production. If something here is wrong, tell me and I'll correct it.
Common questions
- What is the difference between ShouldBeUnique and WithoutOverlapping in Laravel?
- ShouldBeUnique takes its lock when the dispatch() statement ends, so a duplicate never reaches the queue at all. The WithoutOverlapping middleware lets duplicates queue and takes its lock in the worker, releasing the job back to the queue if another instance is already running.
- Does a Laravel unique job lock expire on its own?
- Not from anything you wrote. Without a UniqueFor value the framework asks for a lock of 0 seconds, and what that means is up to the store: no expiry at all on Redis, and 86,400 seconds on the database store, because DatabaseLock substitutes its own default timeout.
- Why did my Laravel job stop being dispatched with no error?
- A ShouldBeUnique lock that was never released drops every later dispatch of that job silently. Since v13.25.0 the framework raises an Illuminate\Queue\Events\UniqueJobSkipped event each time that happens, so listening for it turns the silence into a log line.
- Does queue:clear remove unique job locks?
- No. queue:clear deletes jobs from the queue connection and never touches the cache, so the laravel_unique_job key survives and blocks the next dispatch. cache:clear doesn't reach it either: on Redis the locks sit on a different connection, and on the database store they're in cache_locks rather than the cache table.
- How do I clear a stuck WithoutOverlapping lock in Laravel?
- php artisan cache:clear --locks clears it, but it's all or nothing: on the database store it empties cache_locks, and on Redis it runs FLUSHDB on the lock connection, which on stock config is also the redis queue's connection, so your queued jobs can go with it. To clear just one, name the store and force it: Cache::store('redis')->lock('laravel-queue-overlap:App\Jobs\YourJob:yourKey')->forceRelease(). Then add expireAfter() so the next one clears itself.
Stay in the Loop
Get the latest posts delivered to your inbox - on your schedule.