# Unique job locks: why ShouldBeUnique stops working

> Why a ShouldBeUnique lock gets stuck or skipped, and each case Skyline releases, takes or flags.

Source: https://boring-observability.dev/skyline/docs/unique-job-locks
Section: Queue control — Skyline for Laravel documentation
Updated: 2026-09-27

---

A `ShouldBeUnique` job that stops dispatching almost always has a lock still held under its key. Laravel discards every later dispatch while that lock exists. It throws nothing, records no failed job and shows nothing in Horizon, and with the default `uniqueFor` of `0` the lock has no expiry to end it. Skyline closes the ways a lock gets left behind and takes the lock on the push paths that skip it. Whatever is still held shows on the [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits) screen.

This page covers what Skyline changes. For how the framework's unique locks behave on their own, and how they interact with `WithoutOverlapping`, read [ShouldBeUnique vs ShouldBeUniqueUntilProcessing and WithoutOverlapping](https://boring-observability.dev/blog/laravel-job-uniqueness-controls).

## Where a unique lock goes wrong

| What happens | Laravel on its own | With Skyline |
| --- | --- | --- |
| A unique job is deleted from Redis, a queue is emptied, or `horizon:clear` runs | The job is gone and its lock stays held | [Lock released](#removal) with each job removed |
| A `ShouldBeUniqueUntilProcessing` job is failed on pickup, before `handle()` | The failure path skips the release, so the lock stays held | [Lock released](#stranded) if this job still owns it, and logged either way |
| A unique job is queued by a chain, a batch, `Queue::push()`, `Queue::later()` or a retry | No lock is taken, so a later `dispatch()` queues a second copy | Unchanged — but the [held lock and its skip count](#every-push) are visible, so duplicates are findable |
| A dispatch is skipped because the lock is held | Nothing is logged or stored | Logged, and [counted per job](#seeing-locks) on the Locks & Limits screen |
| A lock is held with no job left to release it | Only visible with `redis-cli` | [Flagged](#seeing-locks), with an owner-checked Release button |

## Deleting a job or emptying a queue

When you dispatch a `ShouldBeUnique` job, Laravel takes a cache lock keyed on the job class and its `uniqueId()`. The lock is released when the job finishes, when it fails for the last time, or, for `ShouldBeUniqueUntilProcessing`, when it starts. Deleting the payload straight out of Redis bypasses all of those paths. The job is gone, the lock is not, and every future dispatch of that job is discarded.

Skyline releases the lock on each path that removes a job from the queue out-of-band:

- Deleting a single job from the dashboard.
- Emptying a queue from the dashboard.
- Running `php artisan horizon:clear`.

It reads the payloads before removing them, rebuilds each queued command, and releases the lock of every one that implements `ShouldBeUnique` through the framework's own `UniqueLock::release()`. A job whose `uniqueVia()` points at another cache store is released on that store. Emptying a large queue sweeps the ready list and the delayed set in batches. There is nothing to configure.

## A ShouldBeUniqueUntilProcessing lock left by a failed pickup

Laravel releases a `ShouldBeUniqueUntilProcessing` lock just before `handle()` runs, and its failure path deliberately skips the release on the assumption that it already happened. A job the worker refuses on pickup never got that far. The two common cases:

- Its `retryUntil()` deadline passed while it waited. `retryUntil()` is fixed at dispatch, so a delayed job or a backlog can carry a job past it before any worker reaches it.
- Its tries ran out on releases that never reached `handle()`, such as a `WithoutOverlapping` or `RateLimited` middleware releasing it each time.

The job fails, the lock stays, and with `uniqueFor` at `0` no later dispatch of that job is ever queued. This is still how the framework behaves on Laravel 13.

Skyline records which lock owner token each job's dispatch took, inside the job's payload. When such a job fails on pickup, Skyline releases the lock with an atomic compare-and-delete against that owner. A lock that has since expired, been released by an earlier attempt, or been taken by a newer dispatch belongs to someone else and is left alone. The release is logged as `unique_lock.released_after_failure`.

```php
// config/horizon.php
'release_stranded_unique_locks' => env('HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS', true),
```

Set it to `false` to keep the framework's behaviour. A lock this job still holds is then logged as `unique_lock.stranded` and not released.

Skyline also fixes the failure message. The worker reports a job picked up after its `retryUntil()` as `MaxAttemptsExceededException: … has been attempted too many times`, which reads as if the job ran and kept failing. On Horizon's failure path, the one the dashboard and the `job.failed` log line read, Skyline substitutes `Laravel\Horizon\Exceptions\RetryWindowExpired`:

```text
App\Jobs\SyncCustomer never ran: its retryUntil() allowed 60s from dispatch, but a worker only picked it up 600s after dispatch.
```

It extends `MaxAttemptsExceededException`, so existing `instanceof` checks still match, and Laravel's own `failed_jobs` record keeps the original message. When the delay alone puts a job past its deadline, Skyline warns at dispatch time too, as `job.retry_until_before_available`.

> **A job whose model was deleted**
>
> If a job refused on pickup can't be unserialized, usually because a `SerializesModels` model was deleted while it waited, the command can't be rebuilt, and Skyline can't work out which store the lock lives in. It logs `unique_lock.stranded` with the exception message instead. A lock on the default cache store still appears on the Locks & Limits screen, where you can release it.

## Chains, batches, Queue::push and retries

Laravel takes a unique lock on the `dispatch()` path only. These paths queue a `ShouldBeUnique` job without taking one:

- the first job of a chain (`Bus::chain()`);
- jobs added to a batch (`Bus::batch()`, pushed through the queue's `bulk()`);
- direct `Queue::push()` and `Queue::later()` calls;
- retries, from `php artisan queue:retry` or the dashboard's Retry button.

A job queued that way doesn't stop a later `dispatch()`, so a second copy lands on the queue beside it. This is the most reported uniqueness problem on the framework's tracker, and it is unchanged through Laravel 13.

Skyline does not change this. A lock taken on the `dispatch()` path is still the only lock there is, and a chain head, a batched job or a direct push queues alongside it. What Skyline does is make the consequences visible: every lock a dispatch holds appears on the [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits) screen with the dispatches it has skipped, so a job running in duplicate shows up as a lock whose skip count is not moving while copies keep running.

> **Working around it**
>
> If duplicates through these paths are a real problem, take your own lock around the push rather than relying on `ShouldBeUnique`: `Cache::lock($key, $seconds)->get()` before queueing, released when the job finishes. Deduplicating the collection before `Bus::batch()` handles the batch case without a lock at all.

There is a second gap behind the first. When a copy queued without the lock starts processing, Laravel force-releases the lock under its key even though that lock belongs to another job — so a duplicate can free the original's lock on its way past. That is framework behaviour and Skyline cannot prevent it from outside; the flagged **Taken elsewhere** and **No job** states on the Locks & Limits screen are how it surfaces.

## Seeing held locks and skipped dispatches

The [Locks & Limits](https://boring-observability.dev/skyline/docs/locks-and-limits) screen lists the unique locks dispatches have taken on the default cache store, each with its job, how long it has been held, when it expires, and how many dispatches it has skipped. A lock whose job has completed or failed, or whose job is gone, is flagged, and a Release button frees it after a confirmation. Below it, the Skipped Dispatches table counts discarded dispatches per job for the past hour and the past day.

The same discards reach your logs once `log_channel` points at a channel that accepts `warning`:

```text
[unique-job:9f2c…] dispatch skipped — a ShouldBeUnique lock is already held; the job was not queued.
```

Laravel 13.25 added a `UniqueJobSkipped` event for a skipped dispatch, but it still logs nothing and records nothing, and earlier versions fire no event at all. Since 1.5 Skyline listens for that event where it exists, which also covers a lock kept on another store through `uniqueVia()`, and falls back to its own cache wrapper on older versions and for the paths the event misses (unique queued listeners and scheduled unique jobs). The full event list is on [Job lifecycle logging](https://boring-observability.dev/skyline/docs/job-lifecycle-logging).

## Limits

- **Redis queues only.** Releasing locks on removal needs the pending and delayed payloads, which only Redis exposes. On other drivers it does nothing, and the lock's TTL, if you set one, is the only backstop.
- **Unserializable payloads are skipped.** A payload naming a class that no longer exists, or a model that was deleted, can't be rebuilt, so its lock can't be released automatically. It is logged, and you can release it from the Locks & Limits screen.
- **Removal releases through the framework.** On Laravel 13.24 and later the framework's release checks the owner token a dispatch stored on the job. Before 13.24, and for a job queued without its own lock, it force-releases the key. A worker that pops a duplicate between the read and the wipe could then run beside another copy. Pause the queue first if that matters.
- **The owner is only known for jobs pushed after upgrading.** Jobs already on the queue carry no recorded owner, so a stranded lock left by one of them is logged but not released.
- **Lock tracking covers the default cache store.** A job whose `uniqueVia()` uses another store, or a lock taken through the `Cache` facade, doesn't appear on the Locks & Limits screen or in discard logging.

> **Set a uniqueFor anyway**
>
> A lock with no TTL has ways to get stuck that no package can see, like a worker killed mid-release or a lost Redis failover. Give `uniqueFor` a value longer than the job's whole lifetime, queue wait and retries included. The rest of the ways a unique lock misbehaves, including a missing `uniqueId()`, are in [ShouldBeUnique gotchas under real traffic](https://boring-observability.dev/blog/laravel-job-uniqueness-controls).


## Common questions

### Why is my ShouldBeUnique job not being dispatched?

A unique lock is still held under its key, and Laravel discards every dispatch while it exists. It throws nothing, records no failed job and shows nothing in Horizon. With the default uniqueFor of 0 the lock never expires. The usual causes are a job deleted straight from Redis or a cleared queue, a ShouldBeUniqueUntilProcessing job failed on pickup, a worker killed while holding the lock, and a missing uniqueId() that makes every instance of the class share one lock.

### Why is a ShouldBeUniqueUntilProcessing lock not released when the job fails?

Laravel releases that lock just before handle() runs, and its failure path skips the release on the assumption it already happened. A job the worker fails on pickup, because its retryUntil() deadline passed or its tries ran out on middleware releases, never reached handle(), so the lock stays held. Skyline records the owner token each dispatch took and releases the lock with an owner-checked compare-and-delete when such a job fails, controlled by horizon.release_stranded_unique_locks.

### Does ShouldBeUnique work in a chain, a batch or with Queue::push()?

Not in Laravel on its own, and Skyline does not change it. The lock is only taken on the dispatch() path, so the first job of a chain, batched jobs, Queue::push(), Queue::later() and retries are queued without it, and a later dispatch() queues a duplicate. What Skyline adds is visibility: the Locks & Limits screen shows every lock a dispatch holds with the dispatches it has skipped, so a job running in duplicate shows up as a held lock whose skip count is not moving. To enforce uniqueness on those paths, take your own Cache::lock() around the push, or deduplicate the collection before Bus::batch().

### How do I release a stuck unique lock?

In Skyline, open Locks & Limits in the dashboard. Locks whose job has completed, failed or disappeared are flagged, and the Release button frees one after a confirmation. The release is owner-checked, so a lock taken again by a newer dispatch since the page loaded returns 409 instead of being freed. Without Skyline, find the laravel_unique_job key in your cache store and force-release it with Cache::lock($key)->forceRelease().

### Should I still set uniqueFor on my jobs?

Yes. A lock with no TTL can still be left behind by a worker killed mid-release or a lost Redis failover, and nothing outside the framework can see that happen. Set uniqueFor longer than the job's whole lifetime, including queue wait, backoff and retries.
