Updated June 2026. Tested on Laravel 13 and PHP 8.4. Part of the Techalyst queue series. This is about avoiding logically duplicate dispatches; for the same job being re-run because of a timeout, see why your job ran twice.
Here is a common need. Every time a product is updated you dispatch a job to rebuild the search index. If ten products are edited in a minute, you do not want ten index rebuilds queued, one is enough. You want at most a single UpdateProductsIndex job in the queue at any time. Laravel gives you a clean, built in way to do this, and it is worth understanding the cache lock it rests on.
The modern way: ShouldBeUnique
Implement the ShouldBeUnique interface and Laravel will refuse to dispatch a second instance while one is already pending or running.
use Illuminate\Contracts\Queue\ShouldBeUnique;
class UpdateProductsIndex implements ShouldQueue, ShouldBeUnique
{
// ...
}
That alone makes the job unique by its class. Often you want uniqueness per record instead, say one job per product, which you control with a uniqueId.
class UpdateProduct implements ShouldQueue, ShouldBeUnique
{
public function __construct(public Product $product) {}
public function uniqueId(): string
{
return $this->product->id;
}
}
You can also bound how long the lock is held, so a stuck job never blocks future dispatches forever.
public $uniqueFor = 120; // release the uniqueness lock after 120 seconds
By default the lock is held until the job finishes processing. If instead you want the next dispatch allowed as soon as processing starts, implement ShouldBeUniqueUntilProcessing. Laravel keeps the lock in your cache, so you need a cache driver that supports locks (Redis, Memcached, DynamoDB, or database).
What it is doing: a cache lock
ShouldBeUnique is sugar over a cache lock, and seeing the manual version makes the behaviour obvious. Before the interface existed, you did this by hand: acquire a lock when dispatching, and only dispatch if you got it.
public function update(Product $product)
{
$product->update(/* ... */);
$lock = Cache::lock('productsIndex', 120);
if ($lock->get()) {
UpdateProductsIndex::dispatch();
}
}
Then you release the lock when the job finishes, and also if it fails, so a failure does not block all future runs.
public function handle(): void
{
// rebuild the search index
Cache::lock('productsIndex')->forceRelease();
}
public function failed(Throwable $e): void
{
Cache::lock('productsIndex')->forceRelease();
}
forceRelease() is used because we do not care which process owns the lock, we just want it gone once the work is done. This is exactly what ShouldBeUnique automates, which is why you should reach for the interface and only hand roll a lock when you need custom behaviour it does not cover.
Wrapping up
To keep just one instance of a job in the queue, implement ShouldBeUnique, add a uniqueId when you want uniqueness per record, and set uniqueFor so a stuck job cannot block dispatches forever. Under the hood it is a cache lock acquired at dispatch and released when the job completes, the same pattern you would otherwise write by hand with Cache::lock. Use the interface; drop to manual locks only for the unusual cases.
More in the series: running the same queued job multiple times and the failed_jobs uuid_unique error. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.