Making Laravel queue jobs retry-safe
29 September 2026 · 7 min read
Queues move slow work out of the request cycle, but they come with a guarantee that surprises people: a job may run more than once. Workers crash, timeouts fire, deploys restart processes. A job that sends an email or charges a card needs to survive being run twice without causing harm.
Make the job idempotent
Idempotent means running it again produces the same end result. Check state before acting, and record what has been done in the same transaction as the side effect where you can.
public function handle(PaymentGateway $gateway): void
{
$order = Order::findOrFail($this->orderId);
if ($order->paid_at !== null) {
return; // already processed on a previous attempt
}
$gateway->charge($order, idempotencyKey: "order-{$order->id}");
$order->update(['paid_at' => now()]);
}Where an external API supports idempotency keys, use them. They protect you in the gap between the remote call succeeding and your database update being saved.
Configure retries and backoff
class SendInvoice implements ShouldQueue
{
public int $tries = 5;
public int $timeout = 60;
public function backoff(): array
{
return [10, 60, 300, 900]; // seconds between attempts
}
}Backoff avoids hammering a struggling dependency. Choose the timeout shorter than the queue connection's retry_after setting; otherwise a slow job is handed to a second worker while the first is still running.
Prevent duplicate work
Implement ShouldBeUnique when only one instance of a job should exist at a time, for example regenerating a report for a given customer. Laravel also provides middleware such as WithoutOverlapping and rate limiting for jobs that must not run concurrently.
Dispatch after commit
If you dispatch a job inside a database transaction, a worker may pick it up before the transaction commits and fail to find the row. Use afterCommit on the job or set after_commit in the queue configuration so dispatch waits for the commit.
Make failures visible
- Use the failed_jobs table and review it. Failures nobody sees are lost work
- Implement a failed() method for cleanup or alerts on permanent failure
- Add alerting when queue depth or job latency grows. Laravel Horizon provides dashboards and metrics for Redis queues
- Run workers under a supervisor and restart them on deploy with php artisan queue:restart
Test the unhappy path: run a job twice in a test and assert the side effect happened once.
Want to talk it through?
Email or call us. No obligation, just a conversation about what you are trying to achieve.
Contact us →