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

<!--more-->

## 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 which of their jobs had timed out, a maintainer guessed that one had, 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()` deletes 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 an existence check on the key either side of the transaction:

```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 probe reads the key directly, so it can't acquire the lock it is 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.