Charge the card, send the receipt, ack the job. The worker lost its Redis connection between step one and step three. The queue delivered the job again. The customer paid twice. Support learned some new words from him.

Retries are not an edge case. Laravel retries failed jobs by design, and you want that, because networks blink. So every job with a side effect must answer one question: what happens if this runs twice. “It will not run twice” is not an answer. It will.

The popular fix is a lock. Cache::lock() around the job body, done. But a lock solves a different problem. It stops two copies from running at the same moment. It does not stop the second copy from running after the first one died halfway. Overlap protection and idempotency are two separate things, and the second one is the one that saves money.

The boring fix is an operation key.

DB::table('payments')->insert([
    'operation_key' => 'order:'.$order->id.':charge',
    'amount'        => $order->total,
    'status'        => 'pending',
]);

Unique index on operation_key. First attempt inserts the row, does the work, writes the result into the same row. Second attempt hits the constraint, catches the exception, reads the existing row and returns its result. From outside both attempts look identical.

The receipt email is the same story, smaller. Sending mail is not idempotent, so record the fact of sending under its own key before you call the mailer. Worst case you record it and crash before sending. One lost email is a support ticket. One duplicate charge is a refund and an angry man.

Derive the key from business meaning, not from the job id. A retry of the same job and an accidental double dispatch must collapse into one key. Job ids differ every time. Order id does not.

One column, one index, one try/catch. Cheaper than the refund.