What Happens When You Clear a Queue in Laravel?
· Updated · 13 min read · Boring Observability
Verified against Laravel 13 · Horizon 5.x
A payment provider goes down for forty minutes. Your default queue climbs from a few hundred jobs to
380,000, and when the provider comes back you have to decide what happens to all of them. Some are still worth
running. Some are webhooks nobody is waiting for any more. And a few of them are ShouldBeUnique jobs,
which is where the obvious fix of clearing the queue goes wrong.
This article is about draining a Redis queue backlog in a Laravel application: how to see what is in it, what
queue:clear deletes, and the unique-lock gotcha that makes a job stop dispatching for good after
somebody empties a queue. It ends with how Skyline handles the same operation, and a runbook you can use either way.
Key takeaways#
- A queue is several Redis keys, not one.
queue:cleardeletes the ready list, the:delayedset and the:reservedset, so it also wipes every retry waiting on a backoff and every job scheduled for next week. - Clearing a queue does not release
ShouldBeUniquelocks. Neitherqueue:clearnorhorizon:clearreads the payloads it deletes, so every unique job in the backlog leaves its lock behind. - With the default
uniqueForof 0, that lock never expires. Every future dispatch of the job is discarded without an exception, a log line or a failed job, until someone deletes the lock by hand. - A backlog also bends uniqueness while it sits there. A
uniqueForshorter than the queue's wait lets duplicates in, and aretryUntil()that passes in the queue strands aShouldBeUniqueUntilProcessinglock. - Release the locks, then delete the payloads. Skyline does it for every dashboard delete, Empty queue and
horizon:clear; on stock Horizon you can do it from tinker before you clear.
Look before you delete#
On Redis, a Laravel queue named default lives under a handful of keys. The ready jobs are a list,
queues:default, which workers lpop from the head. Jobs dispatched with a delay, and jobs
released for a retry, wait in a sorted set, queues:default:delayed, scored by the time they become due.
Jobs a worker is running right now sit in queues:default:reserved, scored by when their lease expires.
(If the mechanics of that lease are unfamiliar,
the life of a Laravel queued job walks through
it.)
So the first thing to find out is how the backlog splits across those keys. Your Redis connection prefix goes in front
of each key name, which is laravel_database_ on a stock install:
redis-cli LLEN laravel_database_queues:default # ready
redis-cli ZCARD laravel_database_queues:default:delayed # scheduled + waiting to retry
redis-cli ZCARD laravel_database_queues:default:reserved # running now
# Sample the oldest ten ready payloads, and count the backlog by job class
redis-cli LRANGE laravel_database_queues:default 0 9
redis-cli --raw LRANGE laravel_database_queues:default 0 -1 \
| jq -r '.displayName' | sort | uniq -c | sort -rn
That last command reads the whole list, so run it against a replica if the backlog is in the millions. What it gives you is the number that decides everything else: which classes make up the backlog. Horizon's dashboard shows the queue's length and its wait time, and its Pending Jobs tab lists recent pending jobs across every queue. It does not tell you what is waiting in one particular queue, and it cannot delete a pending job.
Run it, or throw it away#
A backlog is not one decision. It is one decision per job class, and the question for each is whether the job is still worth running now that it is late.
-
Worth running. Charging a card, provisioning an account, writing a ledger entry. These drain by
running, and the work is capacity: raise
maxProcesseson the supervisor, letbalance => 'auto'move workers onto the deep queue, and keep whatever rate limiting protects the dependency that just recovered. Pointing 200 workers at an API that has been up for ninety seconds is how a forty-minute outage becomes a two-hour one. - Stale. "Your export is ready" notifications for exports that were retried by hand, cache warms, search reindexes that the next scheduled run will redo anyway. These should be deleted, not run, because running them costs capacity the first group needs.
Most real backlogs are a mix, which is why "clear the whole queue" is rarely the right unit. It is still the tool Laravel gives you.
What queue:clear deletes#
php artisan queue:clear and Horizon's php artisan horizon:clear both end up in the same
method, RedisQueue::clear(), which runs one Lua script:
-- Illuminate\Queue\LuaScripts::clear()
-- KEYS: queues:default, queues:default:delayed, queues:default:reserved, queues:default:notify
local size = redis.call('llen', KEYS[1]) + redis.call('zcard', KEYS[2]) + redis.call('zcard', KEYS[3])
redis.call('del', KEYS[1], KEYS[2], KEYS[3], KEYS[4])
return size
Two consequences are easy to miss. The delayed set goes with it, so a clear also deletes every job waiting out a
backoff and every job dispatched with ->delay(now()->addDays(7)). And the reserved set goes too, so a job
running during the clear has no reservation to fall back on: if its worker dies before it finishes, nothing will
migrate it back.
What the script does not do is return the payloads. It deletes the keys inside Redis and hands back a count, and nothing in PHP ever sees the jobs it removed. That is the root of the next problem.
The unique lock that outlives its job#
When you dispatch a job that implements ShouldBeUnique, Laravel takes a cache lock before the job is
pushed. The key is built from the class and the job's uniqueId():
class SyncCustomerToCrm implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function __construct(public Customer $customer) {}
public function uniqueId(): string
{
return (string) $this->customer->id;
}
}
// Lock key: laravel_unique_job:App\Jobs\SyncCustomerToCrm:42
A second dispatch that finds the lock held is dropped. The lock is released in exactly three
places, all of them inside the worker: when the job completes, when it fails for the last time, and (for
ShouldBeUniqueUntilProcessing) just before handle() starts. Every one of those paths needs
a worker to pop the job.
A cleared job is never popped. Its payload is gone and its lock is not. And the lock's lifetime comes from
uniqueFor, which defaults to 0, meaning no expiry. So after the clear:
- The next scheduler run dispatches
SyncCustomerToCrmfor customer 42. - The dispatch finds
laravel_unique_job:App\Jobs\SyncCustomerToCrm:42held, and returns without pushing anything. - That happens again on every run after it, indefinitely.
Nothing throws or logs, and the dashboard shows an empty queue. Customer 42 stops syncing.
Every customer who had a sync in the backlog stops syncing, not only customer 42. The failure shows up days later as a data problem ("why is the CRM stale for some accounts?"), and the person investigating it has no reason to connect it to a queue somebody cleared during last week's incident. A failed unique dispatch is silent by design, which the ShouldBeUnique vs WithoutOverlapping post covers in more detail.
Any way of deleting payloads without running them has the same effect. LREM in redis-cli, a
DEL on the queue key, a script that trims the list: none of them know a lock exists. Even
FLUSHDB can do it when your cache is on Redis, because Laravel's default config keeps the cache in
database 1 and the queue in database 0. Flushing the queue's database leaves every lock in place.
Finding locks that are already stranded#
If you have cleared a queue with unique jobs in it before, some of these locks may be sitting in your cache right now. Where to look depends on your cache store. On Redis, the keys carry the cache prefix and live in the cache database:
redis-cli -n 1 --scan --pattern '*laravel_unique_job:*'
Since Laravel 11, a fresh application's default cache store is the database, and there the locks are rows:
SELECT `key`, expiration FROM cache_locks WHERE `key` LIKE '%laravel_unique_job%';
A lock is stranded if its job is not in the ready list, the delayed set or the reserved set. Once you have checked
that, forceRelease() removes it through the same store the dispatcher uses, prefix included:
Cache::lock('laravel_unique_job:App\Jobs\SyncCustomerToCrm:42')->forceRelease();
One wrinkle: if the job class defines displayName(), the key uses an xxh128 hash of the display name
instead of the class name, so search by uniqueId rather than by class.
Releasing the locks before you clear#
The fix is an ordering change: read the payloads, release each unique job's lock, and only then delete. On stock
Horizon you can do that from php artisan tinker. This version mirrors what the framework's own worker does
when it releases a lock, including decrypting jobs that implement ShouldBeEncrypted:
use Illuminate\Bus\UniqueLock;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Queue;
$queue = Queue::connection('redis');
$redis = $queue->getConnection();
$key = $queue->getQueue('default'); // "queues:default"; the client adds the prefix
$locks = new UniqueLock(Cache::store());
$released = 0;
$release = function (string $payload) use ($locks, &$released) {
$command = json_decode($payload, true)['data']['command'] ?? null;
if (! is_string($command)) {
return;
}
try {
$job = unserialize(str_starts_with($command, 'O:') ? $command : decrypt($command));
} catch (Throwable $e) {
return; // class renamed since dispatch: leave it for the TTL
}
if ($job instanceof ShouldBeUnique) {
$locks->release($job); // honours uniqueVia() per job
$released++;
}
};
for ($i = 0, $n = $redis->llen($key); $i < $n; $i += 1000) {
foreach ($redis->lrange($key, $i, $i + 999) as $payload) $release($payload);
}
for ($i = 0, $n = $redis->zcard("$key:delayed"); $i < $n; $i += 1000) {
foreach ($redis->zrange("$key:delayed", $i, $i + 999) as $payload) $release($payload);
}
$released;
Then run php artisan horizon:clear --queue=default, which also purges Horizon's own pending-job records so
the dashboard does not keep listing jobs that no longer exist.
Pause the queue's workers first. Between the moment the script reads a payload and the moment the clear deletes it, a
worker can pop that job and start running it with its lock already released, and a fresh dispatch can then run
alongside it. Stock Horizon can only pause everything (horizon:pause) or a whole supervisor
(horizon:pause-supervisor), so during an incident that usually means stopping more than the one queue.
Two ways a backlog bends uniqueness before you touch it#
A backlog you leave alone causes two more problems, and both come from the same fact: a unique job's lock is timed from dispatch, while its work happens whenever a worker reaches it.
The lock expires while the job waits#
Say the scheduler dispatches SyncCustomerToCrm every five minutes with uniqueFor set to
300 seconds, which feels generous for a job that runs in two. Now the queue's wait time is forty minutes. Each
lock expires while its job is still in the list, the next scheduler run takes a new lock and pushes a second copy, and
by the time workers catch up there are eight syncs queued per customer.
On current Laravel 13 releases a job carries its lock's owner token in its payload and releases only that lock, so
the first copy finishing does not free the lock the second copy holds. The duplicates still run, though. The fix is
a uniqueFor longer than the worst queue wait you would tolerate, not the job's runtime.
The deadline passes while the job waits#
A job with retryUntil() is checked against that deadline when a worker pops it, before it reaches
handle(). If the backlog outlasted the deadline, the worker fails the job on the spot with
MaxAttemptsExceededException. For a plain ShouldBeUnique job that failure releases the lock.
For ShouldBeUniqueUntilProcessing it does not: the framework's failure path skips the release because it
assumes the job already released it when processing started. This job never started, so the lock stays, and with no
uniqueFor it stays forever. Draining the backlog by running it strands these locks as surely as clearing
it does.
The cache_locks query and redis-cli --scan above find these too. After a large drain, check
for MaxAttemptsExceededException failures on ShouldBeUniqueUntilProcessing classes, and
release their locks once you have confirmed no copy is queued.
Those two cases pull uniqueFor in opposite directions. Too short and a backlog admits duplicates.
Unset and any path that skips the worker strands the lock permanently. Set it longer than your worst tolerable
queue wait, and treat anything that deletes jobs out-of-band as something that must also release their locks.
How Skyline drains a queue#
We built Skyline, a drop-in replacement for Horizon, partly because this operation needed a UI that releases unique locks when it deletes jobs. The same backlog looks like this in Skyline:
-
Pause the one queue. Per-queue pause
stops workers picking up from
defaultwhile they keep serving every other queue, and autoscaling stops assigning processes to it. Jobs already running finish normally. - Open the queue and see what is in it. Clicking a queue on the dashboard lists the jobs waiting inside it, in order, with the arguments each was dispatched with.
-
Delete the stale part. Search narrows the list by class or by argument
(
SendExportReadyNotification, orcustomer_id: 42), and the matching jobs can be selected and deleted together. If the whole queue is stale, Empty queue removes everything. - Resume, and the jobs that were worth running drain through the workers you left pointed at them.
Every one of those deletions releases the unique locks of the jobs it removes, and so does
php artisan horizon:clear. Skyline reads the payloads before the delete, in batches of 1,000 from both the
ready list and the delayed set, reconstructs each command, and releases its lock through the framework's own
UniqueLock::release(). That means a job whose uniqueVia() points at a different cache store
is released on that store, and on Laravel versions with lock owner tokens only the lock that job acquired is touched.
There is nothing to configure. The details are in the
unique job locks docs.
The release only works on Redis queues, because only Redis exposes the pending payloads.
A payload whose class no longer exists cannot be reconstructed, so its lock is left for its TTL. And jobs that
implement ShouldBeEncrypted are currently skipped by the releaser, so for those, the tinker version above
is still the one to use.
Draining by running the backlog is covered as well. When a worker refuses a ShouldBeUniqueUntilProcessing
job because its retryUntil() deadline passed in the queue, Skyline releases the lock that job's dispatch
took, as long as that same owner still holds it, and logs unique_lock.stranded either way. The failed job
says what happened, "never ran: its retryUntil() allowed 60s from dispatch, but a worker only picked it up 600s after
dispatch", instead of the framework's "attempted too many times". If you would rather keep Laravel's behaviour,
HORIZON_RELEASE_STRANDED_UNIQUE_LOCKS=false turns the release off and keeps the log line.
To check whether anything is wedged after the drain, open the Locks & Limits screen. It lists the unique locks held since Skyline started tracking them, flags the ones whose job is gone or already finished, and counts the dispatches each lock has skipped. Locks stranded before you installed it are not in that list, so the scan above is still how you find those. Skyline also logs each skipped dispatch, which Laravel doesn't; see job lifecycle logging.
A drain runbook#
- Stop the source if it is still producing: fix or feature-flag whatever is dispatching the flood.
- Measure the backlog per key (ready, delayed, reserved) and per job class.
- Split the classes into "still worth running" and "stale".
- Pause the affected queue, or the narrowest thing your tooling can pause.
- Release the unique locks of everything you are about to delete, then delete it. Keep the delayed set if it holds scheduled jobs you still want.
- Resume, with worker counts and rate limits sized for the dependency, not for the backlog.
- Afterwards, scan for stranded
laravel_unique_joblocks and forMaxAttemptsExceededExceptiononShouldBeUniqueUntilProcessingjobs.
For the rest of the unique-lock failure modes, see ShouldBeUnique vs WithoutOverlapping in Laravel. For why a job can run twice when its lease expires during a slow drain, see timeout vs retry_after. And Skyline vs Horizon covers what else changes when you swap the package.
Frequently asked questions
Does php artisan queue:clear release ShouldBeUnique locks?
No. On Redis, queue:clear and horizon:clear both call RedisQueue::clear(), a Lua script that deletes the queue's keys inside Redis and returns only a count. No PHP code sees the deleted payloads, so nothing releases the unique locks their jobs acquired at dispatch. Laravel only releases those locks from the worker, when a job completes, fails for the last time, or starts processing, and a cleared job never reaches a worker.
What does queue:clear delete on a Redis queue?
Four keys: the ready list (queues:name), the delayed sorted set (queues:name:delayed), the reserved sorted set (queues:name:reserved) and the notify list. Deleting the delayed set means every job waiting out a retry backoff and every job dispatched with a delay is removed too. Deleting the reserved set means a job running during the clear has no reservation left, so if its worker dies it is never migrated back.
Why did my ShouldBeUnique job stop running after I cleared the queue?
Because its unique lock outlived the cleared job. The lock's lifetime comes from uniqueFor, which defaults to 0, meaning no expiry. Every later dispatch finds the lock held and is silently dropped, with no exception, log line or failed job. Find the lock under laravel_unique_job: in your cache store and remove it with Cache::lock($key)->forceRelease() once you have confirmed no copy of the job is still queued.
How do I clear a Laravel queue without stranding unique locks?
Read the payloads before deleting them. For each job in the ready list and the delayed set, unserialize the command and, if it implements ShouldBeUnique, release its lock with Illuminate\Bus\UniqueLock::release(), which honours uniqueVia(). Then run horizon:clear or queue:clear. Pause the queue's workers first, so none of them pops a job whose lock has just been released. Skyline does this automatically for dashboard deletes, Empty queue and horizon:clear.
Can a queue backlog cause duplicate ShouldBeUnique jobs?
Yes, when uniqueFor is shorter than the queue's wait time. The lock is timed from dispatch, so it expires while the job is still waiting in the list, and the next dispatch acquires a new lock and pushes a second copy. A job dispatched every five minutes with uniqueFor at 300 seconds will queue about eight copies during a forty-minute backlog. Set uniqueFor longer than the worst queue wait you would tolerate, not the job's runtime.
Keep reading
11 min read
Instrumenting Guzzle in Laravel: Logging and Metrics for Every Outgoing Request
Log and measure every outgoing HTTP call in Laravel with a Guzzle middleware and on_stats, including...
10 min read
Dispatching Laravel Jobs After the Transaction Commits
Why Laravel jobs dispatched in a transaction can run before it commits, and how afterCommit behaves with...