laravelmastery
SnippetsArticlesSeriesNews

Queues in production · Lesson 3 of 3

Queues in production · Lesson 3

Make the job safe to run twice

Run it for realQueues

The batch marks the invoice sent only when the PDF and the email have both succeeded. A worker can still mark the order paid, then die before the job is deleted. Laravel runs the job again. Charging the card a second time is the bug. Setting the status to paid, after you have checked it, is fine.

Video

01 Leave when this attempt already landed

Store the attempt on the order. If that attempt is already recorded, or the order is already paid, return. Returning is success: the queue deletes the job.

Example.phpPHP
class Example
{
    public function handle(): void
    {
        $order = Order::query()->findOrFail($this->orderId);

        if ($order->paid_attempt === $this->attempt || $order->status === 'paid') {
            return;
        }

        $order->forceFill([
            'status' => 'paid',
            'paid_attempt' => $this->attempt,
        ])->save();
    }
}

The article on idempotent workers walks through the same guard and a test that calls handle() twice.

Jobs that don’t fall over: designing idempotent queue workers.

The check and the write should see a consistent row. A lock from WithoutOverlapping, or a database transaction around the read and the update, stops two workers passing the check together.

02 Call handle() twice and expect one paid order

The test does not need a worker. Build a pending order, run the job, run it again, and read the row. The second call hits the guard and returns. The status stays paid, and there is still one order.

PHP
it('marks the order paid once when handle runs twice', function () {
    $order = Order::factory()->create(['status' => 'pending']);
    $job = new MarkOrderPaid($order->id, 'attempt-1');

    $job->handle();
    $job->handle();

    $fresh = $order->fresh();

    expect($fresh->status)->toBe('paid')
        ->and($fresh->paid_attempt)->toBe('attempt-1');
});

A second attempt string is a different question. If the provider really should be contacted again, that is a new job with a new attempt, dispatched on purpose. A retry of this job keeps the attempt it was given. The test above is the retry.

Wrap the read and the write in a transaction when two workers might pass the check together. The unique job and the overlapping lock are the other ways to stop that. The guard inside handle() is still required, because a lock ends when the job ends and a retry after that should find the order already paid.

Recap

You can return when this attempt is already stored, so a second run does not charge again.

  • Returning is success. The queue deletes the job.
  • The attempt string is the payment, not the worker's attempt count.
  • The check and the write still need a lock or a transaction if two workers can pass the check together.

Check yourself · 3 questions

Question 1 of 3The worker dies after the status is saved and before the job is deleted. The retry runs. What should handle() do?

A second run of the same attempt does not charge the card again. Next, the supervisor that shows the work.

← PreviousBatches and then()

On this page

  • Leave when this attempt already landed
  • Call handle() twice and expect one paid order
  • Recap
  • Check yourself

Queues in production

  • 1 Overview

Hand the work off

  • 2 Keep the request short
  • 3 The job class
  • 4 Dispatch from actions, not controllers

When it goes wrong

  • 5 Retries, backoff and timeouts
  • 6 Failed jobs you can replay
  • 7 Unique jobs and WithoutOverlapping
  • 8 Batches and then()

Run it for real

  • 9 Make the job safe to run twice
  • 10 Horizon in production

Queues in production

Lessons

  1. 01Overview

Hand the work off

  1. 02Keep the request short
  2. 03The job class
  3. 04Dispatch from actions, not controllers

When it goes wrong

  1. 05Retries, backoff and timeouts
  2. 06Failed jobs you can replay
  3. 07Unique jobs and WithoutOverlapping
  4. 08Batches and then()

Run it for real

  1. 09Make the job safe to run twice
  2. 10Horizon in production

Get the Friday email

One snippet and the week’s Laravel news, in your inbox every Friday morning. Unsubscribe whenever you like.

laravelmastery

Practical Laravel learning for working developers, made in the UK.

Learn

  • Snippets
  • Articles
  • Series

Stay current

  • News
  • Friday email
  • RSS feed

Company

  • About
  • Privacy
  • Terms
© 2026 Laravel Mastery. Independent and not affiliated with Laravel Holdings Inc.Designed and built by Half Shell Studios

We remember the videos you watch, where you left off, and which series lessons you mark as read, against this connection’s address. Nothing is stored until you agree.