A customer gets charged twice. Or the same shipping confirmation lands in their inbox three times. You read the job, and the logic is fine. It is fine for one run, which is the problem: a queued job does not always run exactly once.

Most advice here is about stopping the second run from happening. That is worth doing, and we have covered it: a retry_after shorter than your timeout will hand the same job to a second worker, and ShouldBeUnique keeps duplicates off the queue in the first place. But neither gets you to zero. This post is about the other half: writing the job so that a second run does no damage.

Why a job runs twice

Even with everything configured properly, these still happen:

  • A worker is killed after the side effect but before the job is marked as done. A deploy, an out-of-memory kill, or a timeout in the middle of the work all do this. The card was charged; the queue never found out.
  • The job outlives retry_after, so the queue makes it visible again and another worker picks it up while the first is still going.
  • Somebody runs queue:retry on a failed job that had already done half its work.
  • The job calls release() itself, after part of the work is complete.

You can make all of these rarer. You cannot make them impossible, because the side effect and the acknowledgement can never be a single atomic step.

Idempotent means the second run changes nothing

A job is idempotent when running it again produces the same end state as running it once. Not "it errors the second time", and not "it usually skips". The second attempt should look at the world, see the work is already done, and quietly stop.

Without a guard both attempts charge the card; with a guard the second attempt is a no-op

The way to get there is to claim the work in the database first, in a way that a second attempt cannot also claim.

Let the database enforce it

The strongest guard is a unique index, because the database checks it atomically no matter how many workers are involved.

Schema::create('captures', function (Blueprint $table) {
    $table->id();
    $table->foreignId('order_id')->constrained();
    $table->string('idempotency_key')->unique();
    $table->string('status')->default('pending');
    $table->timestamps();
});

Then let the insert be the thing that decides who proceeds:

use Illuminate\Database\UniqueConstraintViolationException;

public function handle(PaymentGateway $gateway): void
{
    try {
        $capture = Capture::create([
            'order_id'        => $this->order->id,
            'idempotency_key' => "order:{$this->order->id}:capture",
        ]);
    } catch (UniqueConstraintViolationException) {
        return;
    }

    $gateway->charge($this->order->total, $this->order->card_token);

    $capture->update(['status' => 'succeeded']);
}

The second attempt cannot insert that row, so it returns before touching the gateway. Note the key is derived from the order, not generated at run time. Str::uuid() here would be different on every attempt and would guard nothing.

Claim the row before you act

If the work is a state change on a record you already have, a conditional update does the same job without a second table. update() returns the number of rows it changed, and only one attempt can see the row in its old state.

$claimed = Order::whereKey($this->order->id)
    ->where('status', 'pending')
    ->update(['status' => 'capturing']);

if ($claimed === 0) {
    return;
}

This is the pattern to reach for when you catch yourself writing if ($order->status === 'pending') and then updating a moment later. Those two statements are separate trips to the database, and two workers can both pass the check before either one writes.

Send an idempotency key to the external API

For the side effects you do not control, the guard has to live at the other end. Most payment and messaging providers accept an idempotency key and will return the original result instead of repeating the operation.

$gateway->charge(
    amount: $this->order->total,
    token: $this->order->card_token,
    idempotencyKey: "order:{$this->order->id}:capture",
);

The same rule applies: derive the key from stable data. If it changes between attempts, the provider sees a brand new request and happily charges again.

A lock narrows the window

A cache lock stops two workers running the same job body at the same time:

$lock = Cache::lock("capture:{$this->order->id}", 120);

if (! $lock->get()) {
    $this->release(30);

    return;
}

try {
    // do the work
} finally {
    $lock->release();
}

This is a useful addition, not a replacement. A lock held by a worker that crashed expires on its own, and the retry then arrives to find nothing blocking it. Keep a database-level guard underneath.

Two guards that do not work

if ($order->paid) { return; } on its own is a check with a gap after it. Two workers can both read false and both go on to charge.

Flipping a boolean after the external call has the same weakness in the other direction. If the process dies between the call and the update, the flag stays false and the next attempt repeats the work. Write the claim before the side effect, not after it.

Wrapping up

Assume every job will run twice eventually, and make the second run boring. Claim the work atomically with a unique index or a conditional update, pass a stable idempotency key to anything external, and treat locks as a way to reduce collisions rather than prevent them. Do that and a duplicate delivery stops being an incident and becomes a line in the log.

Related