Looking to hire Laravel developers? Try LaraJobs

laravel-unique-job-locks maintained by mohannadelemary

Description
Find out why a ShouldBeUnique job won't dispatch: list and explain the unique-job locks held in your Redis cache store.
Last update
2026/10/18 00:53 (dev-main)
License
Downloads
1

Comments
comments powered by Disqus

Laravel Unique Job Locks

tests Latest Version on Packagist License: MIT

Two read-only Artisan commands that tell you why a ShouldBeUnique job won't dispatch.

The problem

A ShouldBeUnique job takes a cache lock when it is dispatched. Only a worker gives it back: when the job finishes, when it fails for good, or (for ShouldBeUniqueUntilProcessing) just before handle(). Until then every dispatch() with the same unique ID is dropped. You get no exception and no log line, only a UniqueJobSkipped event that nobody listens to.

So when a worker is killed mid-job (OOM, SIGKILL, a deploy), or someone runs queue:clear or horizon:clear, the lock stays. With the default uniqueFor = 0 it never expires. The usual fix people try, cache:clear, often does nothing, because Laravel's default Redis store keeps locks on lock_connection (default, db 0) and not on the cache connection (db 1). See laravel/framework#36927 and laravel/horizon#1678.

Finding the lock by hand means rebuilding its key yourself: the Redis connection prefix, then the cache prefix, then laravel_unique_job:, then the class (or, since Laravel 13.36, a hash of displayName()), then uniqueId(). It also has to be on the right connection and in the right database, and uniqueVia() can change all of that.

Example

An invoice sync stops running for invoice 42. The worker that had it was OOM-killed an hour ago:

$ php artisan queue:unique-locks
+----------------------+-----------+---------------+------------------+
| Job                  | Unique ID | TTL           | Owner            |
+----------------------+-----------+---------------+------------------+
| App\Jobs\SyncInvoice | 42        | never expires | 2t0h1wzMtZd48LPW |
+----------------------+-----------+---------------+------------------+
  1 lock(s) in cache store [redis]. Run queue:unique-lock:why <job> --id=<id> to see why one is held.

$ php artisan queue:unique-lock:why App/Jobs/SyncInvoice --id=42

  Job .......................................................................... App\Jobs\SyncInvoice (ShouldBeUnique)
  Unique ID ....................................................................................................... 42
  Cache store .................................................................................. redis (default store)
  Lock connection ............................................................................................ default
  Lock key ................................................................ laravel_unique_job:App\Jobs\SyncInvoice:42
  Redis key ................................ billing-database-billing-cache-laravel_unique_job:App\Jobs\SyncInvoice:42
  uniqueFor ............................................................................... 0 (the lock never expires)
  Status ...................................................................................................... LOCKED
  Owner ............................................................................................. 2t0h1wzMtZd48LPW
  Expires ............................................................................................. never (no TTL)

   WARN  Why: No pending, delayed or reserved job on [default] holds this lock, so it is orphaned. Usually the job
         was removed without its lock being released: queue:clear, horizon:clear, a flushed queue, or a deleted job.
         It has no TTL (uniqueFor is 0), so it will never expire on its own.

Installation

composer require mohannadelemary/laravel-unique-job-locks

Requires PHP 8.3+ and Laravel 12 or 13. The service provider is auto-discovered. There's no config to publish.

Usage

List the held locks

php artisan queue:unique-locks
php artisan queue:unique-locks --job="App\Jobs\SyncInvoice"   # or --job=App/Jobs/SyncInvoice
php artisan queue:unique-locks --store=locks

This command uses SCAN, not KEYS, on the store's lock connection, so it is safe on a busy server.

Explain one lock

php artisan queue:unique-lock:why App/Jobs/SyncInvoice --id=42
  • --id is what uniqueId() returns for the dispatch that was skipped. You can leave it out if uniqueId() doesn't need constructor arguments.
  • The store comes from --store, then from uniqueVia() when it can be called without constructor arguments, then from the default store. That's the same order Illuminate\Bus\UniqueLock uses, apart from --store.
  • To find the queued copy of the job, the command searches the default queue connection's queue. Change that with --connection=redis --queue=default,invoices. The reason it prints is one of these:
What the command finds What it tells you
The job is pending or delayed The lock is working as designed. It goes when that copy finishes, or when it starts for ShouldBeUniqueUntilProcessing.
The job is reserved and its retry_after hasn't passed A worker is running it. If that worker died, the job is retried after the reservation runs out.
The job is reserved and its retry_after has passed The worker most likely crashed. The next worker retries the job and then releases the lock.
The job isn't on the queue at all The lock is orphaned, usually by queue:clear or horizon:clear. You also see whether it will ever expire.

It finds the job by the lock key (and, on Laravel 13.36+, the owner) that dispatch() writes into the payload's hidden context. It reads the pending list and the delayed and reserved sets with LRANGE and ZRANGE, at most --limit jobs from each (10000 by default). If a list is longer than that and the job wasn't found, the command says the result is incomplete instead of calling the lock orphaned, so you can run it again with a higher --limit.

Releasing a lock

Both commands are read-only. To remove an orphaned lock, add --release:

php artisan queue:unique-lock:why App/Jobs/SyncInvoice --id=42 --release

The command warns you that the job can then run twice, and it waits for a "yes". The default answer is no, and with --no-interaction the command never releases anything. The release checks the owner, so if a new dispatch took the lock after the inspection, the command leaves that lock alone.

To stop this from happening again, give long-lived unique jobs a uniqueFor.

Limitations

  • Only Redis cache stores are supported. Any other store fails with an error that names the store.
  • Listing doesn't work on Redis clusters, because SCAN would have to run on each node. Explaining a single lock works on any connection.
  • Only Redis queues are searched for the job that holds the lock. With any other queue driver you still get the key, the TTL and a general explanation.
  • If uniqueId() or uniqueVia() depend on constructor arguments, pass --id (and --store).

Roadmap

  • Database cache stores (the cache_locks table), so the commands work without Redis.

Testing

composer test      # Pest against a real Redis on REDIS_HOST:REDIS_PORT (127.0.0.1:6379 by default)
composer analyse   # PHPStan level 8
composer lint      # Pint

The tests dispatch real jobs and run on both phpredis and predis, with a Redis connection prefix, a cache prefix, a separate lock_connection and a uniqueVia() store.

License

MIT. See LICENSE.