Skip to content
All posts

Laravel’s ShouldBeUnique Does Not Make a Job Idempotent

June 26, 2026·Read on Medium·

Queue de-duplication stops overlap. Idempotency survives retries, crashes and replay.

The bug usually shows up after the part everyone trusts.

You dispatch a Laravel job that charges a card, sends a receipt, or syncs an order to a partner API. You add ShouldBeUnique, watch the duplicate dispatches disappear, and move on. A week later a worker times out, the job is retried, and the customer gets charged twice. The queue layer did what you asked. The business operation did not.

That distinction matters more than most Laravel queue discussions admit. Laravel gives you several anti-duplicate tools now: ShouldBeUnique, ShouldBeUniqueUntilProcessing, WithoutOverlapping, and in current docs even DebounceFor. They solve useful, real problems. None of them, on their own, answers the question a payment system, email pipeline, or stock reservation flow actually cares about: “If this work is replayed, do we perform the side effect again?”

That is the idempotency question. It lives closer to the operation than the queue.

What ShouldBeUnique Actually Buys You

Laravel’s queue docs are clear about the guarantee. When a ShouldBeUnique job is dispatched, Laravel tries to acquire a lock for the job’s uniqueId. If the lock is already held, the new job is not dispatched. That is a dispatch-time filter backed by a cache lock.

It is a good tool when the problem is noisy enqueueing:

  • A user clicks the same button five times
  • A webhook fan-out path dispatches the same follow-up work more than once
  • Several app nodes try to queue the same background refresh

In those cases, stopping duplicate queue entries early is cheap and sensible.

The important part is what the guarantee does not say. It does not say the side effect behind the job can happen only once. It does not say a retry cannot run the job body again. It does not say a worker crash magically writes a durable execution record for you. It says Laravel will skip dispatching a second job while the unique lock is held.

Here is a small, verified Laravel-shaped example that combines queue uniqueness with overlap protection:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;

class SyncInvoiceToLedger implements ShouldQueue, ShouldBeUnique
{
use Queueable;

public int $uniqueFor = 300;

public function __construct(public int $invoiceId)
{
}

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

public function middleware(): array
{
return [
(new WithoutOverlapping($this->invoiceId))
->shared()
->expireAfter(180),
];
}

public function handle(): void
{
// Do the work here.
}
}

This job is unique by invoiceId, so duplicate dispatches with the same key are skipped while the unique lock is alive. That is useful. It is still not idempotency.

The Guarantee Changes Once a Worker Starts

Laravel also documents ShouldBeUniqueUntilProcessing. That contract unlocks the job immediately before processing begins. In other words, it narrows the guarantee even further. You are preventing duplicate queue entries while a job waits, not while the operation completes.

That can be exactly what you want for stale refresh work. Rebuild the search index for product 42 five times in a minute and only one queued copy needs to wait around. Once processing starts, letting a fresher update queue behind it might be fine.

WithoutOverlapping solves a different boundary again. It is not about dispatch suppression but runtime concurrency, and Laravel implements it with atomic locks while warning that timeouts or unexpected failures can leave a lock behind long enough that you should consider expireAfter(…). That warning is not ceremonial. It tells you the framework expects real failure, not textbook failure.

There is another subtle detail in the docs that is easy to miss: WithoutOverlapping only blocks jobs of the same class by default. If two different job classes use the same lock key, they can still overlap unless you call shared(). That is one of those details that looks minor in a code review and expensive in production.

Current Laravel docs also include DebounceFor, which is useful when repeated dispatches should collapse into the latest one in a short window for a resource that gets refreshed often rather than mutated permanently. Think “search index update” or “rebuild dashboard cache”, not “charge this order”. Debouncing is about freshness, while idempotency is about correctness after replay in production.

Retries Are Where the Illusion Breaks

The reason people over-trust uniqueness locks is simple: duplicate dispatch feels like the same problem as duplicate execution.

It is not.

Laravel’s queue docs spell out two mechanics that matter here. First, each queue connection has a retry_after setting. If a job has been processing past that window without being released or deleted, it can be made available again. Second, the worker timeout setting must be shorter than retry_after. Laravel explicitly warns that if the timeout is longer than retry_after, your jobs may be processed twice.

That sentence should reset how you think about queue correctness.

A unique job can still reach a state like this:

  1. The job starts and calls an external API.
  2. The worker hangs, times out, or dies before Laravel marks the job complete.
  3. The queue system retries the job because, from its point of view, the work did not finish safely.
  4. The external API receives the same business command again.

If the side effect itself has no durable replay protection, ShouldBeUnique did not save you. It could not save you. The second execution did not come from a second dispatch attempt while the lock was held. It came from the same operation being replayed after failure.

That is why “my job is unique” and “my operation is idempotent” should never share the same mental shelf.

What Real Idempotency Looks Like

A real idempotency boundary has to survive the queue entry.

It needs a stable operation key, a durable record, and a decision point that runs before you perform the side effect again. If the downstream provider supports request idempotency, you pass that same operation key through. Stripe is a clean example: it stores the first result for a key and returns that same result on later retries, including 500 responses, while rejecting retries whose parameters do not match the original request.

That is closer to what production-safe replay protection looks like.

The pattern in Laravel terms is simple:

  • Generate one operation key for the business action, not for the queue message
  • Persist that key in your database with the operation status
  • Check the record inside a transaction before re-running the side effect
  • Pass the same key to any downstream API that supports idempotency

Here is what that pattern looks like with real Laravel code. This assumes payment_operations.operation_key has a unique index and that the operation key is created before the job is dispatched:

<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use Stripe\StripeClient;

class CaptureOrderPayment implements ShouldQueue
{
use Queueable;

public function __construct(
public int $orderId,
public string $operationKey,
public string $stripeCustomerId,
public string $paymentMethodId,
public int $amountInCents,
)
{
}

public function handle(StripeClient $stripe): void
{
DB::transaction(function () use ($stripe): void {
$operation = DB::table('payment_operations')
->where('operation_key', $this->operationKey)
->lockForUpdate()
->first();

if ($operation?->status === 'succeeded') {
return;
}

if (! $operation) {
DB::table('payment_operations')->insert([
'operation_key' => $this->operationKey,
'order_id' => $this->orderId,
'status' => 'processing',
'created_at' => now(),
'updated_at' => now(),
]);
}

$paymentIntent = $stripe->paymentIntents->create([
'amount' => $this->amountInCents,
'currency' => 'usd',
'customer' => $this->stripeCustomerId,
'payment_method' => $this->paymentMethodId,
'confirm' => true,
'off_session' => true,
], [
'idempotency_key' => $this->operationKey,
]);

DB::table('payment_operations')
->where('operation_key', $this->operationKey)
->update([
'status' => 'succeeded',
'provider_reference' => $paymentIntent->id,
'updated_at' => now(),
]);
}, attempts: 5);
}
}

The queue can replay this job, the worker can restart, and a human can retry the failed record. The operation key still gives you one place to answer the only question that matters: “Did we already do this exact business action?”

The Four Tools, Without the Hand-Waving

This is the map I wish more teams used.

ShouldBeUnique

  • Use it when duplicate dispatches are wasteful.
  • Think “do not enqueue the same background refresh twice right now.”
  • Do not mistake it for side-effect protection.

ShouldBeUniqueUntilProcessing

  • Use it when you only care about uniqueness while the job is waiting.
  • Good for stale work where a fresh dispatch can reasonably follow once processing starts.
  • Weakest guarantee of the four.

WithoutOverlapping

  • Use it when one resource should not be mutated concurrently.
  • Think “only one job should touch this account balance or provider state at a time.”
  • Remember shared() if different job classes hit the same resource key.

DebounceFor

  • Use it when repeated dispatches should collapse into the latest request.
  • Great for refresh workloads.
  • Terrible substitute for payment correctness.

Persistent idempotency record

  • Use it when a replayed operation would cost money, create duplicates, or corrupt state.
  • This is the control that belongs next to emails, payments, inventory moves, and partner API writes.
  • It is the only tool in this list that protects the operation instead of just the queue behavior around it.

That last one is usually the expensive answer. It is also the honest answer.

A Laravel Workflow That Holds Up Under Retries

If the job performs a real side effect, the workflow I trust looks like this:

  1. Generate an operation key when the command is accepted. Not when the queue worker starts.
  2. Dispatch the job with that key in the payload.
  3. Add ShouldBeUnique only if duplicate dispatch noise is a problem.
  4. Add WithoutOverlapping if concurrent execution against the same resource would be unsafe.
  5. Inside the job, open a transaction and lock the operation record before you talk to the external system.
  6. Pass the same operation key to any provider that supports request idempotency.
  7. Treat the queue as a delivery mechanism, not as the source of truth for whether the business action already happened.

That sequence is less elegant than “just add an interface to the job class.” It is also the sequence that still makes sense after the first ugly timeout.

One more practical test helps here. Ask what happens if the job body reaches the irreversible line, then the worker dies before your app records success. If the answer is “we would send the email again” or “we would submit the same charge again unless the provider saves our key,” you are not done. You have queue hygiene, not correctness, and that is still cheaper to admit in design review than explaining to finance why one customer paid twice, another received two refund emails, and the warehouse reserved stock for an order your app thought had failed.

The Laravel Mistake Behind a Lot of Expensive Bugs

Laravel makes background work easy, and that is usually good for teams. The cost is that queue control primitives look closer to business correctness than they really are.

ShouldBeUnique is a queue admission rule.

WithoutOverlapping is a concurrency rule.

DebounceFor is a freshness rule.

Idempotency is an operation rule. Put it where the operation lives or pay for the confusion later.

The queue can help you avoid duplicate work. It cannot decide whether the second charge, second email, or second stock decrement is acceptable. That decision belongs to your application, and production will eventually ask whether you knew the difference before it really mattered.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Laravel’s ShouldBeUnique Does Not Make a Job Idempotent — Hafiq Iqmal — Hafiq Iqmal