# ShouldBeUnique vs WithoutOverlapping: Neither Sets a Lock Expiry for You

**Author:** Mozex | **Published:** 2026-09-21 | **Tags:** Laravel, PHP, DevOps | **URL:** https://mozex.dev/blog/25-shouldbeunique-vs-withoutoverlapping-neither-sets-a-lock-expiry-for-you

---


Here's a key that was sitting in my Redis a few minutes ago:

```text
$ 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()`](https://github.com/laravel/framework/blob/v13.32.0/src/Illuminate/Cache/RedisLock.php) 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:

```php
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](https://laravel.com/docs/scheduling) 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:

```text
$ 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:

```text
$ 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](https://mozex.dev/blog/17-5-laravel-scheduler-failures-that-only-show-up-in-production#2-codewithoutoverlappingcode-locks-expire-mid-task).

## 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:

```php
$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()`:

```bash
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:

```text
 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](https://github.com/laravel/framework/issues/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](https://laravel.com/docs/queues) 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`:

```text
$ 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](https://github.com/laravel/framework/pull/58907):

```text
$ 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](https://github.com/laravel/framework/pull/61039), in v13.25.0, a dropped dispatch raises an event:

```php
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:

```php
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:

```text
["lock held inside the transaction", true]
["lock held after the rollback", false]
["jobs on the queue", 0]
```

The model-deleted path came from [#54000](https://github.com/laravel/framework/pull/54000) and the rollback path from [#55420](https://github.com/laravel/framework/pull/55420) and [#61234](https://github.com/laravel/framework/pull/61234), with [#60906](https://github.com/laravel/framework/pull/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](https://mozex.dev/blog/16-the-laravel-bug-your-tests-will-never-catch#every-way-to-fix-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:

```php
$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:

```text
$ 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](https://laravel.com/docs/queues) 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:

```php
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:

```php
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 `-1`s.

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](https://mozex.dev/blog/12-5-laravel-queue-failures-that-only-show-up-in-production#4-unique-job-locks-that-expire-too-early). 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](https://mozex.dev/blog/12-5-laravel-queue-failures-that-only-show-up-in-production#3-deployments-killing-jobs-mid-execution) 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.