skip to content

In Laravel, how do ShouldBeUnique and uniqueId prevent duplicate queued jobs, and why might a unique job silently stop being dispatched?

level: seniorimportance: should knowfreq 44%

answer

  1. a cache lock taken at dispatch
  2. key: class plus uniqueId()
  3. held until done or finally failed
  4. #[UniqueFor] bounds the lock
  5. UniqueJobSkipped event, 13.25

basics

~20 s

ShouldBeUnique makes dispatch take a cache lock keyed by job class and uniqueId(); if it is held, the dispatch is silently skipped. The lock is freed when the job completes or finally fails, so a lost job can block later dispatches.

solid answer

~40 s

When a `ShouldBeUnique` job is dispatched, `PendingDispatch` tries to acquire a cache lock named `laravel_unique_job:<class>:<uniqueId>` on the default cache store, or on the store `uniqueVia()` returns. If another instance holds it, nothing is pushed and no exception is thrown; since 13.25 a `UniqueJobSkipped` event fires. The lock is released after the job completes or fails its last attempt — or, with `ShouldBeUniqueUntilProcessing`, just before it starts. Without `#[UniqueFor(seconds)]` (or `$uniqueFor` / `uniqueFor()`) the lock has no TTL of its own, so if the job vanishes — a cleared queue, a lost payload — later dispatches keep being skipped. Uniqueness also needs one cache all servers share, and it does not apply to jobs inside batches.

code

php · 24 lines
php
<?php

namespace App\Jobs;

use App\Models\Product;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Attributes\UniqueFor;

#[UniqueFor(3600)]
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    public function __construct(public Product $product) {}

    public function uniqueId(): string
    {
        return (string) $this->product->id;
    }

    public function handle(): void {}
}

go deeper

for a junior

Recall that ShouldBeUnique plus uniqueId() stops the same job being queued twice and that it uses a cache lock.

for a middle

Explain when the lock is taken and released, what the key contains, and how UniqueFor and uniqueVia change it.

for a senior

Diagnose silently skipped dispatches: missing lock TTL, long retries, a coarse key or a per-server cache, and add observability for skips.

for a principal

Choose between dispatch-time uniqueness, overlap control and idempotent job design as the team's default for duplicate work.

## What ShouldBeUnique promises `Illuminate\Contracts\Queue\ShouldBeUnique` is an empty marker interface. A job that implements it may have at most one instance **queued or running** per unique key. A typical use is `UpdateSearchIndex` for a product: saving a product ten times in a minute should not queue ten re-indexes. It is **dispatch-side deduplication**. It does not stop two different job classes from touching the same row, and it is not the same as limiting concurrent execution — that is what the `WithoutOverlapping` job middleware does. ## How the lock works 1. `SearchIndex::dispatch($product)` returns a `PendingDispatch`. 2. In its destructor, before pushing, `shouldDispatch()` sees `ShouldBeUnique` and asks `Illuminate\Bus\UniqueLock` to acquire a lock. 3. The key is `laravel_unique_job:` plus the job class (or a hash of `displayName()` if the job defines it) plus `:` and the result of `uniqueId()` — or an empty string if there is none, which makes the whole class unique. 4. The store is the default cache store, or whatever `uniqueVia()` returns. 5. The lock's lifetime comes from `uniqueFor()`, then `#[UniqueFor(3600)]` or `$uniqueFor`, defaulting to `0`. 6. If the lock is acquired, the job is pushed, carrying the lock owner token. If not, the dispatch is dropped, `dispatch()` still returns normally, and Laravel 13.25+ fires `Illuminate\Queue\Events\UniqueJobSkipped`. On the worker, the lock is released: - for `ShouldBeUnique`, after the job **completes** or **fails its final attempt** — a release back onto the queue keeps it; - for `ShouldBeUniqueUntilProcessing`, just **before** `handle()` runs, so a new dispatch is accepted while the current one is still working; - when the transaction rolls back, if the job was dispatched with `afterCommit`. ## Why a unique job can stop dispatching | Cause | What you see | Remedy | |---|---|---| | Lock without a TTL and the job vanished (queue cleared, payload lost) | every later dispatch skipped | set `#[UniqueFor]` longer than the worst-case wait plus run time | | Long retry schedule | new dispatches skipped for hours | shorter backoff, or `ShouldBeUniqueUntilProcessing` | | `uniqueId()` too coarse | unrelated work suppressed | include every field that distinguishes the work | | Local cache per server (`array`, or a `file` store per host) | duplicates still get through | point every server at one shared cache | A lock of `0` seconds means no expiry of its own; on Redis the key simply stays until something releases it. That is why a bounded `UniqueFor` is a common production rule: a lock that outlives its job should eventually expire rather than suppress work indefinitely. The docs list the lock-capable cache drivers: `memcached`, `redis`, `dynamodb`, `database`, `file` and `array`. They also warn that all web servers and containers must share the same central cache, or each one will happily grant its own lock. ## Investigating a stuck unique job When product re-indexes stop happening and nothing appears in `failed_jobs`, work through the lock rather than the worker: 1. Confirm dispatches are being skipped — listen for `UniqueJobSkipped`, or log around the dispatch call. 2. Work out the key: `laravel_unique_job:App\Jobs\UpdateSearchIndex:` plus the id the job's `uniqueId()` returns, under the cache store's own prefix. 3. Check whether a job with that key is actually queued, reserved or waiting for a retry. 4. If nothing is, the lock is orphaned; release it through the cache store and add a bounded `#[UniqueFor]` so the next orphan expires on its own. ## Edges worth knowing - **Batches.** Unique constraints do not apply to jobs inside a `Bus::batch`. - **Debounced jobs.** Since 13.6 a job can carry `#[DebounceFor(seconds)]`; it may not also implement `ShouldBeUnique`, and dispatching one that does throws a `LogicException`. - **Silence.** Because the skip is silent, log or count `UniqueJobSkipped` when a missing job would matter. - **Scope of the key.** The class name is part of the key, so two job classes with the same `uniqueId()` do not block each other. ## Choosing the variant - `ShouldBeUnique` — at most one queued-or-running instance; suits expensive idempotent recomputations. - `ShouldBeUniqueUntilProcessing` — at most one waiting instance; a change during processing still gets its own run, which suits "sync the latest state" jobs. - Job middleware against overlap — when duplicates may queue but must not run at the same time. Uniqueness is a cheap filter, not a correctness guarantee: the job should still tolerate running twice.

  • When should a Laravel job use ShouldBeUniqueUntilProcessing instead of ShouldBeUnique?
    When a change that arrives while the job is running must still trigger another run. `ShouldBeUniqueUntilProcessing` releases the lock just before `handle()` starts, so a new dispatch is accepted during processing; `ShouldBeUnique` would skip it until the running job finishes or fails for good.
  • How do you notice that dispatches of a unique Laravel job are being skipped?
    Since Laravel 13.25 a skipped dispatch fires `Illuminate\Queue\Events\UniqueJobSkipped` with the job, so a listener can log or count it. Before that event existed, the skip left no trace, which is why stuck locks were hard to spot.

saying these in an interview costs you the question

  • A duplicate dispatch of a ShouldBeUnique job throws an exception
  • The unique lock is released as soon as a worker picks the job up
  • Uniqueness works across servers even if each uses a local cache
  • ShouldBeUnique stops two instances running concurrently but allows duplicates to queue
  • Unique constraints also apply to jobs added to a batch