# Unique Job Middleware

> Laravel checks ShouldBeUnique only at dispatch(). This middleware checks the lock again when a worker picks the job up, so chains, batches and retries respect it.

Source: https://boring-observability.dev/open-source/unique-job-middleware
Install: `composer require boring-o11y/unique-job-middleware`
Requires: PHP 8.1+, Laravel 10 to 13, any queue driver
Code and full documentation: https://github.com/boring-o11y/unique-job-middleware
License: MIT
Status: experimental

---

> **Experimental**
>
> This is version 0.1, and it relies on a protected property of Laravel's queue job class that a framework release could change. Try it on one job class before adding it everywhere.

Laravel checks `ShouldBeUnique` in one place: `dispatch()`. It takes the lock there, and if another copy already holds it, the dispatch is quietly dropped. A job that reaches the queue any other way skips that check.

That includes the first job of a `Bus::chain()`, jobs in a `Bus::batch()`, `Queue::push()` and `Queue::bulk()`, and every `queue:retry` or press of Horizon's retry button. A unique job queued that way runs even while another copy holds the lock, so two copies run side by side. We cover the wider set of cases in [Laravel ShouldBeUnique: what breaks under real traffic](https://boring-observability.dev/blog/laravel-job-uniqueness-controls).

## A second check when the worker picks the job up

```php
class RecalculateInvoice implements ShouldQueue, ShouldBeUnique
{
    public function middleware(): array
    {
        return [new EnsureUniqueLock];
    }
}
```

The middleware looks at the lock when the job is about to run. If this job holds it, or it is free, the job runs. If another job holds it, this one is deleted without running and a `UniqueJobSkipped` event fires, which you can log or count.

The hard part is telling "my lock" from "someone else's lock". Laravel takes the lock under a random owner token and then throws the token away. The package records it in the job's payload at push time, so the middleware has something to compare.

## When in doubt, it runs the job

A job is only skipped on proof that another job holds its lock. If the cache store has no locks, Redis errors, or the job was queued before the package was installed, the job runs as it would have without it. In a chain, a skipped job ends the chain, as a discarded dispatch does. In a batch it counts as a success, so the batch does not wait forever.

## Limits

- If `uniqueFor` expires while a job waits and a newer dispatch takes the lock, the older job is the one skipped.
- On Laravel 10 and early 11.x, `ShouldBeUniqueUntilProcessing` jobs release their lock before middleware runs, so they are not protected there. Plain `ShouldBeUnique` jobs are, on every supported version.

