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.
Queues in production · Lesson 3
Make the job safe to run twice
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.
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.
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?