boring-o11y / unique-job-middleware
A queue job middleware that checks a ShouldBeUnique job's lock again when it runs, and skips the copies queued past it.
Package info
github.com/boring-o11y/unique-job-middleware
pkg:composer/boring-o11y/unique-job-middleware
Requires
- php: ^8.1
- illuminate/bus: ^10.0|^11.0|^12.0|^13.0
- illuminate/cache: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/queue: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- laravel/horizon: ^5.24
- orchestra/testbench: ^8.14|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.5|^11.0|^12.0
- predis/predis: ^2.0|^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A queue job middleware that checks a ShouldBeUnique job's lock again when a worker picks it up, and skips the job if another job holds it.
use BoringO11y\UniqueJobMiddleware\Middleware\EnsureUniqueLock; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; class RecalculateInvoice implements ShouldQueue, ShouldBeUnique { public function __construct(public int $invoiceId) {} public function uniqueId(): string { return (string) $this->invoiceId; } public function middleware(): array { return [new EnsureUniqueLock]; } }
Why
Laravel checks uniqueness in exactly one place: dispatch(). It takes the lock there, and if the lock is already held the dispatch is quietly discarded. Every other way onto the queue skips that check:
- the first job of a
Bus::chain() - jobs in a
Bus::batch() Queue::push(),Queue::later()andQueue::bulk()queue:retryand Horizon's retry buttonBus::dispatch()/Bus::dispatchToQueue()
A unique job queued any of those ways runs even while another copy holds the lock, so two copies run side by side.
The middleware can't just check "is the lock held?". A normally dispatched job holds its own lock while it waits, so the middleware has to know whether the lock is this job's. Laravel takes the lock with a random owner token and throws the token away. This package records it in the payload so the middleware can compare.
Install
composer require boring-o11y/unique-job-middleware
The service provider is discovered automatically. Then add new EnsureUniqueLock to the middleware() of each unique job you want protected.
What happens when the job is picked up
| The job's unique lock is… | Result |
|---|---|
| held by this job | runs |
| free | takes the lock, then runs |
| held by another job | deleted without running; UniqueJobSkipped is fired |
| unreadable (store has no locks, Redis error) | runs |
| not recorded (job queued before install) | runs |
A job is only skipped when there is proof another job holds its lock.
When a job takes a free lock, it takes it under the owner its payload records, or under its own uuid if it has none. If it is released back onto the queue, its next attempt still counts as the holder. For ShouldBeUnique jobs Laravel releases that lock when the job finishes, just as for a dispatched job.
Chains and batches
- Chains: a skipped job ends its chain. The jobs after it are not dispatched. That's also what Laravel does when a chained unique job's own dispatch is discarded.
- Batches: a skipped job counts as a success for its batch, so the batch doesn't wait forever for a job that will never report.
ShouldBeUniqueUntilProcessing
These jobs release their lock as they start, so the middleware never takes a free lock for them. It only skips a copy while another job still holds the lock.
On Laravel 10 and early 11.x, the framework releases that lock before middleware runs. An until-processing job therefore always finds its lock free there and runs. Plain ShouldBeUnique jobs are protected on every supported version.
Seeing skipped jobs
A skip is silent unless you listen for it. In Horizon, a skipped job shows as completed.
use BoringO11y\UniqueJobMiddleware\Events\UniqueJobSkipped; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; Event::listen(function (UniqueJobSkipped $event) { Log::warning('Skipped a duplicate unique job', [ 'job' => $event->job->resolveName(), 'job_id' => $event->job->uuid(), 'lock' => $event->lockKey, ]); });
The event carries the queue job ($job), the job instance that didn't run ($command), and the lock key ($lockKey).
How it works
At push. A Queue::createPayloadUsing() hook adds a record to the payload of every unique job:
"uniqueJobLock": {"key": "laravel_unique_job:App\\Jobs\\RecalculateInvoice:42", "owner": "Hx9…"}
The hook runs before the job is serialized, so it sees the job instance. owner is filled in only when the push happens inside a dispatch that just took the lock: PendingDispatch, a unique queued listener, or the scheduler queueing a unique job. It is found with a backtrace, and only for unique jobs. Every other push records owner: null. A retry re-queues the stored payload, so the record comes with it.
At pickup. The middleware reads the record and compares it with the lock's current owner in the cache store.
On skip. Returning from middleware without calling $next isn't enough on its own. After middleware returns, Laravel's CallQueuedHandler force-releases the unique lock by key, and that lock belongs to the other job. So the middleware:
- deletes the job
- marks the queue job as released, which stops that release without putting the job back on the queue
That marker is a protected property on Illuminate\Queue\Jobs\Job, which every built-in queue driver's job extends.
Limits
- Locks that expire. If
uniqueForruns out while a job waits and another dispatch takes the key, the older job is skipped and the newer one does the work. - Other code that force-releases by key. Laravel's own unique-lock releases (a job failing, a model-not-found) are ownerless. The middleware stops only the release that follows a skip.
- Custom queue job classes that don't extend
Illuminate\Queue\Jobs\Jobpass straight through.
Requirements
PHP 8.1+, Laravel 10–13. Works with any queue driver, and with Laravel Horizon.
Testing
docker compose run --rm app composer install docker compose run --rm app vendor/bin/phpunit docker compose run --rm -e REDIS_CLIENT=predis app vendor/bin/phpunit
The suite runs twice: once on Laravel's Redis queue, and once on Horizon's queue driver.
License
MIT