Skyline

Unique job locks: why ShouldBeUnique stops working

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

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

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 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 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 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 on the Locks & Limits screen
A lock is held with no job left to release it Only visible with redis-cli Flagged, 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.

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

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

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

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.

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.

Queue control, not just queue monitoring.

Skyline is a drop-in replacement for Laravel Horizon that lets you act on what you see — pause a queue, jump a job to the front, drain a backlog. $139 once, every app you run it on.

Buy Skyline — $139 once

Secure checkout by Anystack. 30 days to change your mind.