Skip to content
4 min read

Laravel queues: retries, failures and what to watch

A queued job will run twice and fail at the worst time. Configuring tries and backoff, making jobs safe to retry, and reading the failed_jobs table before users do.

On this page
  1. A job will run more than once
  2. Retries belong on the job, not in a flag
  3. The transaction trap
  4. failed_jobs is a monitor, not an archive
  5. Three numbers
  6. The three that matter

Queues are where an application's reliability assumptions get tested, because a queued job runs later, in a different process, possibly twice, possibly while something it depends on is broken.

Laravel makes dispatching one trivial. That is a good thing, and it leads reliably to the same three problems.

A job will run more than once

Start here, because everything else depends on it. A worker can be killed mid-job. A job can time out and be released. A deploy can restart the process. The guarantee is at least once, never exactly once.

So the question to ask about every job you write is what happens if this runs twice.

php
class CaptureLead implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 30;

    public function __construct(private readonly array $payload) {}

    public function handle(LeadRepository $leads): void
    {
        // Converges on one row whether this is the first run or the fourth.
        $leads->upsertBySourceRef(
            source:    $this->payload['source'],
            sourceRef: $this->payload['id'],
            attributes: $this->payload,
        );
    }
}

That upsert behind a unique constraint is the thing making five attempts safe. Without it, five attempts are five rows, and the retry policy you configured for reliability has quietly become a data-corruption policy. Same ordering argument as in idempotent APIs and webhooks: idempotency first, retries second, never the reverse.

Retries belong on the job, not in a flag

A global --tries=3 treats an email and a payment identically, and they are not identical.

php
class NotifyPartner implements ShouldQueue
{
    public int $tries = 8;              // third-party API, worth persisting with
    public int $maxExceptions = 3;      // but not through 8 real errors

    public function backoff(): array
    {
        return [10, 30, 60, 300, 900];  // seconds; last value repeats
    }

    public function retryUntil(): \DateTime
    {
        return now()->addHours(6);      // after which the attempt is pointless
    }
}

Four knobs answering four different questions. tries caps attempts. maxExceptions caps how many of those attempts may actually throw, which diverges from tries because a job released while a lock was held has not thrown anything. backoff spaces the attempts so a struggling dependency is not hammered by its own retry storm. And retryUntil gives the job a wall-clock deadline, which matters for anything time-sensitive: a notification retried eight hours later is worse than one that failed.

The transaction trap

The most common Laravel queue bug, and it presents as a race condition because that is exactly what it is.

php
DB::transaction(function () use ($data) {
    $lead = Lead::create($data);
    CaptureLead::dispatch($lead->id);   // worker may pick this up before commit
});

The job hits the queue immediately. A worker claims it, queries for the id, and finds nothing, because the transaction has not committed. It fails with "No query results for model", you re-run it by hand, it works perfectly, and nobody can reproduce it.

Two fixes, and you want the second one:

php
CaptureLead::dispatch($lead->id)->afterCommit();
php
// config/queue.php — make it the default for a connection
'redis' => [
    // …
    'after_commit' => true,
],

Turn it on at the connection level and forget about it. The per-dispatch call is for the exceptions, not the rule.

failed_jobs is a monitor, not an archive

When attempts run out the job lands in failed_jobs with its payload and exception. Most teams discover this table for the first time during an incident, which is the wrong time to be learning its schema.

Give the job a failure path:

php
public function failed(\Throwable $e): void
{
    Log::error('lead capture failed', [
        'source_ref' => $this->payload['id'] ?? null,
        'exception'  => $e->getMessage(),
    ]);

    // Something a human will actually see
    Notification::route('slack', config('alerts.slack'))
        ->notify(new JobFailed(static::class, $this->payload));
}

Then alert on the rate rather than the count. A handful of failures a day from a flaky third-party API is background noise. Fifty in ten minutes is an incident, and the difference between noticing now and noticing tomorrow is usually whether the payloads are still re-runnable.

queue:retry all exists and is extremely tempting at 2am. Read a few of the failures first, because retrying a thousand jobs that all fail for the same reason gets you a thousand identical failures and a load spike.

Three numbers

Queue depth is useful alongside the next one and misleading on its own, since a deep queue that drains is a busy system rather than a broken one.

Age of the oldest job is the real signal. A job that has been waiting twenty minutes means workers are stuck, dead, or simply outnumbered, and all three need you.

Failure rate, meaning growth in failed_jobs per minute rather than the total, which only ever goes up and so tells you nothing.

And one operational detail that causes more confusion than anything else on this page: workers load your code at boot and hold it. A deploy without queue:restart leaves workers cheerfully processing jobs with the previous release's code. Put it in the deploy script next to the migration step and never think about it again.

The three that matter

Laravel's queue is easy to adopt and easy to misconfigure in ways that only surface under load, which is to say in ways you find in production.

Make every job safe to run twice. Dispatch after commit. Set retries per job rather than globally.

Then watch the oldest job's age and the failure rate. Those two numbers tell you the queue is in trouble well before anyone files a ticket about an email that never arrived.

Frequently asked

How many times should a Laravel job retry?

It depends on what the job does. A call to a flaky third-party API deserves several attempts with increasing backoff; a job that charges a card should retry only if the charge is idempotent. Set it per job with the tries property rather than globally, because one number cannot be right for both.

Why does my Laravel job fail with a model not found?

Almost always because the job was dispatched inside a database transaction and picked up by a worker before that transaction committed. The worker queries for a row that is not visible yet. Use dispatchAfterResponse or afterCommit so dispatch happens once the transaction is durable.

What is the difference between tries and maxExceptions in Laravel?

tries caps the total number of attempts, including those that time out or are released back to the queue. maxExceptions caps how many of those attempts may throw an unhandled exception. A job that is released repeatedly because a lock is held has not thrown anything, so the two counts can diverge.

How do I monitor Laravel queues in production?

Watch three numbers: queue depth, oldest job age, and the rate at which failed_jobs grows. Depth alone is misleading because a busy queue that drains is healthy. Oldest job age tells you whether anything is actually stuck, and the failure rate tells you whether workers are succeeding.

Found this useful?Share on XShare on LinkedIn

Related reading