Retrying a failed queue job sounds simple: set $tries = 3 and move on. But retries have a sharp edge. If a job charges a card, then fails after the charge but before finishing, the retry charges the card again. The same trap applies to sending emails, calling external APIs, or incrementing counters. Here’s how to retry Laravel jobs safely — so a retry re-runs the job without re-running its side effects.
Start with tries and backoff
First, the easy part. Tell the job how many times to retry and how long to wait between attempts. Backoff matters: hammering a flaky external service instantly just fails three times in a row. Spacing retries out gives the transient problem time to clear.
class ChargeOrder implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tries = 3;
// Wait 30s, then 60s, then 120s between attempts.
public array $backoff = [30, 60, 120];
public function __construct(public Order $order) {}
}
The real problem: duplicated side effects
Now the dangerous part. Say the job charges the customer and then marks the order paid:
public function handle(PaymentGateway $gateway): void
{
$gateway->charge($this->order->total, $this->order->payment_token); // ✅ succeeds
$this->order->markAsPaid(); // 💥 throws here
}
If markAsPaid() throws, the job fails and retries — and the retry calls charge() again. The customer is charged twice. The retry did exactly what you told it to; the job just wasn’t safe to run more than once.
Make the job idempotent
The fix is idempotency: running the job twice should produce the same result as running it once. The simplest version is to check state before doing the irreversible thing, so a retry skips the part that already happened.
public function handle(PaymentGateway $gateway): void
{
// If a previous attempt already charged, don't charge again.
if (! $this->order->isPaid()) {
$charge = $gateway->charge(
amount: $this->order->total,
token: $this->order->payment_token,
// An idempotency key lets the gateway itself dedupe retries.
idempotencyKey: "order-{$this->order->id}",
);
$this->order->markAsPaid($charge->id);
}
// Safe to repeat: these just need to end up done.
SendReceipt::dispatch($this->order);
}
Two things are doing the work here. The isPaid() guard means a retry won’t re-charge. And the idempotency key — supported by Stripe, PayPal, and most serious payment APIs — tells the gateway “if you’ve already seen this key, return the original result instead of charging again.” Belt and suspenders: even if your guard has a race condition, the gateway refuses the duplicate.
Handle the final failure explicitly
After the last retry fails, the job is done trying. Don’t let that happen silently — use the failed() method to react: alert someone, mark the order for manual review, or notify the customer. A job that fails into the void is a bug waiting to be discovered by an angry user.
public function failed(\Throwable $e): void
{
$this->order->markPaymentFailed();
Log::error("Charge failed for order {$this->order->id}", ['error' => $e->getMessage()]);
// Notify the team / the customer here.
}
A few more guardrails
- Wrap multi-step DB work in a transaction so a mid-job failure doesn’t leave half-written state the retry has to untangle.
- Use
retryUntil()instead of$trieswhen “keep trying for 10 minutes” fits better than a fixed count. - Don’t retry un-retryable errors. A 400 from an API won’t fix itself — fail fast rather than burning three attempts on it.
- Keep jobs small. One job, one unit of work. Big jobs have more places to fail halfway and more side effects to dedupe.
The takeaway
Retries are only safe if your jobs are safe to repeat. Set sensible tries and backoff, but spend your real effort making the job idempotent — guard irreversible actions behind a state check, use idempotency keys for external calls, and handle the final failure in failed(). Do that, and a retry becomes a quiet recovery instead of a double charge.
