A Laravel queue turns a 400ms HTTP request into a 40ms one, but only if the worker survives the job. The failure modes are boring and repeatable: a job that silently retries forever, a timeout that kills a worker mid-transaction, a failed jobs table nobody reads. This guide covers the configuration and code patterns that keep background processing predictable under load.
Set retry_after higher than your longest job timeout, make every job idempotent, cap tries explicitly, and monitor the failed jobs table. Most "stuck queue" incidents trace back to one of those four.
How does a Laravel queue worker actually process a job?
A worker is a long-lived PHP process that polls a driver (Redis, database, SQS) for the next available job, unserializes it, resolves it through the service container, and calls handle(). When handle() returns without throwing, the worker deletes the job from the queue.
Two clocks matter. The first is --timeout, enforced by the worker using pcntl_alarm; when it fires, the child process is killed with SIGKILL. The second is retry_after in config/queue.php, enforced by the queue driver itself. If retry_after is shorter than --timeout, a slow job gets released back onto the queue while the original worker is still running it. The job executes twice.
| Setting | Enforced by | Failure if misconfigured |
|---|---|---|
--timeout | Worker process (SIGALRM) | Job killed, worker restarts, no exception recorded |
retry_after | Queue driver | Duplicate execution of long-running jobs |
$tries | Job class | Infinite retries, queue backlog grows |
$backoff | Job class | Retry storm against a downed dependency |
Configure laravel queue retry and backoff correctly
Retries are cheap; retry storms are not. Laravel lets you declare the policy on the job class, which beats a global default because different jobs have different blast radii.
namespace App\Jobs;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class SyncInvoiceToLedger implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $timeout = 90;
public int $uniqueFor = 3600;
/** @var array */
public array $backoff = [10, 30, 120, 300];
public function __construct(public int $invoiceId) {}
public function uniqueId(): string
{
return 'invoice-ledger-' . $this->invoiceId;
}
public function handle(): void
{
// idempotent work here
}
}
The exponential $backoff array gives a flaky upstream service time to recover instead of hammering it four times in four seconds. ShouldBeUnique prevents the same invoice from being queued twice while a job is in flight, which is the most common source of duplicate ledger entries in my experience running billing queues.
Set retry_after in config/queue.php to at least double your highest $timeout. A 90-second job with retry_after of 90 is a duplicate waiting to happen.
Make jobs idempotent before you scale workers
At-least-once delivery is the contract for every Laravel queue driver. Assume every job runs twice and design accordingly. Two patterns cover most cases.
Guard with a database constraint. If the job writes a row, make the write naturally idempotent with updateOrCreate or a unique index plus insertOrIgnore. A unique index on (invoice_id, ledger_period) makes the second execution a no-op instead of a duplicate.
Guard with a state check. Read the current state inside a transaction and bail if the work is already done.
public function handle(): void
{
DB::transaction(function () {
$invoice = Invoice::whereKey($this->invoiceId)
->lockForUpdate()
->firstOrFail();
if ($invoice->ledger_synced_at !== null) {
return; // already processed
}
LedgerEntry::create([
'invoice_id' => $invoice->id,
'amount' => $invoice->total,
]);
$invoice->forceFill(['ledger_synced_at' => now()])->save();
});
}
lockForUpdate() serializes concurrent attempts on the same invoice. Without it, two workers can both read ledger_synced_at = null and both insert. The lock costs one row-level wait; the duplicate costs a reconciliation ticket.
Diagnose the laravel failed jobs table instead of ignoring it
Failed jobs land in the failed_jobs table after exhausting $tries. That table is the cheapest observability you have, and most teams never query it. Run the migration with php artisan queue:failed-table followed by php artisan migrate if it is missing.
# Inspect the most recent failures
php artisan queue:failed
# Retry one job by id
php artisan queue:retry 8f3c1a2e-4b91-4d0a-9c77-2f1e6b0a5d33
# Retry everything and prune old rows
php artisan queue:retry all
php artisan queue:prune-failed --hours=168
The exception column holds the full stack trace. Group by the first line of that column to find your top failure class, usually a connection timeout to a third-party API or a ModelNotFoundException from a deleted parent record. The latter means the job should have used deleteWhenMissingModels = true rather than retrying five times.
Alert on the failed_jobs row count, not on worker CPU. A queue that has been failing for six hours looks perfectly healthy from the outside.
Use laravel Horizon queue monitoring for Redis workloads
If you run Redis, Horizon replaces queue:work with a supervised pool and exposes throughput, wait time, and failure rate per queue. Configure it in config/horizon.php:
'environments' => [
'production' => [
'supervisor-critical' => [
'connection' => 'redis',
'queue' => ['critical'],
'balance' => 'auto',
'processes' => 10,
'tries' => 3,
'timeout' => 60,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'emails'],
'balance' => 'auto',
'processes' => 20,
'tries' => 5,
'timeout' => 120,
],
],
],
Split queues by latency budget, not by domain. A password-reset email and a nightly report export should not compete for the same workers. The balance => 'auto' strategy shifts processes toward whichever queue is backing up, which absorbs traffic spikes without manual tuning.
| Queue | Latency target | Suggested timeout | Retries |
|---|---|---|---|
critical | < 5s | 30s | 3 |
default | < 60s | 120s | 5 |
exports | minutes | 900s | 2 |
webhooks | < 30s | 60s | 8 with backoff |
Common laravel queue mistakes that appear under load
Four issues show up repeatedly once throughput climbs past a few hundred jobs per minute.
- Serializing Eloquent models instead of IDs.
SerializesModelsre-queries the model on wake, which is usually fine, but a deleted record throwsModelNotFoundExceptionand burns a retry. Pass scalars for jobs that must not fail on missing data. - Dispatching inside a transaction. The job can start before the transaction commits and read stale data. Use
DB::afterCommit()or theafter_commitqueue connection setting. - Logging inside a hot loop. Each
Log::infois a synchronous write. A job that logs per row turns a 2-second task into a 40-second one. - Running migrations while workers are live. A worker holding an old serialized payload against a renamed column fails on every retry. Deploy code and schema together, then restart workers.
For the query side of the same problem, jobs that issue hundreds of queries per execution, the patterns in fixing Eloquent N+1 in Laravel apply directly inside handle(). If you are weighing worker throughput against request throughput, the trade-offs in that same production tuning write-up are worth reviewing alongside the official queue documentation and my project notes.
Frequently asked questions
What should retry_after be set to?
At least twice your longest job $timeout. If your slowest job can run 120 seconds, set retry_after to 240. Anything lower risks the driver releasing a job that is still executing, producing duplicate side effects.
How do I stop a job from retrying forever?
Declare public int $tries = 5; on the job class. Without it, the worker falls back to --tries, which defaults to 1 for queue:work but can be set higher in supervisor configs, causing silent infinite loops.
Should I use the database or Redis driver?
Redis for throughput above a few jobs per second; the database driver is fine for low-volume internal work and gives you the queue contents in SQL. Redis needs persistence configured or a restart loses in-flight jobs.
How do I test a queued job?
Use Queue::fake() to assert dispatch, then call handle() directly in a separate test to verify behavior. Assert idempotency by invoking handle() twice and checking row counts.
The configuration is small: explicit $tries, exponential $backoff, retry_after above --timeout, idempotent handlers, and a monitored failed jobs table. Get those right and the laravel queue stops being the part of the system you learn about from customer support tickets.