DEV Community

Sanad Kit
Sanad Kit

Posted on

Laravel queues in production: 9 settings that stop jobs from failing silently (or running twice)

Queues are the part of a Laravel app that breaks without telling you. A request that throws a 500 shows up in your error tracker within seconds. A job that times out, gets retried after its worker was killed, or runs before its database row exists can just... not do its work. Days later, a customer asks why their invoice email never came.

Below are nine settings I check on every production Laravel app that uses queues. Everything here is verified against the official Laravel queue docs (12.x and 13.x) and the default config/queue.php in the Laravel skeleton. Nothing needs a package beyond the framework.

Laravel 13 note: the 13.x docs now show PHP attributes for job settings: #[Tries(10)], #[Timeout(120)], #[Backoff([1, 5, 10])], #[FailOnTimeout], #[DeleteWhenMissingModels] (from Illuminate\Queue\Attributes). The public properties used below still work. In 13.x the framework reads the attribute first and falls back to the property.

1. retry_after must be longer than your worker --timeout

These two settings sound similar but aren't the same:

  • retry_after (per connection, in config/queue.php) is how many seconds the queue waits before it assumes a reserved job is lost and hands it to another worker.
  • --timeout (on queue:work, default 60s) is how long a worker lets a job run before killing it.

If --timeout is longer than retry_after, a slow job gets handed to a second worker while the first one is still processing it, so it runs twice. The docs are explicit: --timeout should always be at least several seconds shorter than retry_after.

// config/queue.php (skeleton default for the database driver)
'database' => [
    'driver' => 'database',
    'connection' => env('DB_QUEUE_CONNECTION'),
    'table' => env('DB_QUEUE_TABLE', 'jobs'),
    'queue' => env('DB_QUEUE', 'default'),
    'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
    'after_commit' => false,
],
Enter fullscreen mode Exit fullscreen mode
# retry_after = 90, so keep the worker timeout clearly below it
php artisan queue:work database --timeout=75
Enter fullscreen mode Exit fullscreen mode

If one job legitimately needs 10 minutes, don't raise retry_after for everything. Put it on its own connection or queue with its own retry_after and a dedicated worker.

SQS is the exception. It has no retry_after and uses the queue's visibility timeout from the AWS console instead.

2. Set $timeout on the job itself (and install PCNTL)

A per-job timeout overrides the CLI flag, which is useful for the one slow export job:

class GenerateMonthlyReport implements ShouldQueue
{
    use Queueable;

    public $timeout = 120;      // seconds; must stay below retry_after
    public $failOnTimeout = true; // don't burn retries on a job that will just time out again
}
Enter fullscreen mode Exit fullscreen mode

Two gotchas from the docs:

  • Job timeouts require the PCNTL PHP extension. Without it, the timeout simply isn't enforced.
  • Blocking I/O (sockets, outgoing HTTP) may ignore the job timeout, so set timeouts on the client too, e.g. Http::timeout(10)->connectTimeout(3).

3. Decide tries deliberately, because the default is one attempt

By default Laravel attempts a job once. That's fine for many jobs, but it bites in two places:

  • When you use WithoutOverlapping or RateLimited middleware, a release back onto the queue counts as an attempt. With tries = 1, a rate-limited job fails instead of waiting.
  • Jobs that call flaky third-party APIs.
class SyncCustomerToCrm implements ShouldQueue
{
    use Queueable;

    public $tries = 10;         // allow releases from middleware
    public $maxExceptions = 3;  // but fail after 3 real exceptions
}
Enter fullscreen mode Exit fullscreen mode

For "keep trying for 30 minutes, however many attempts that takes", use retryUntil(). It takes precedence over tries when both are defined:

use DateTime;

public function retryUntil(): DateTime
{
    return now()->addMinutes(30);
}
Enter fullscreen mode Exit fullscreen mode

4. Add backoff so retries don't hammer a failing dependency

Without backoff, a job that throws is released for immediate retry. When a payment gateway or SMTP server is down, that burns all your attempts in a few seconds. An array gives you escalating delays:

// 1s before the 2nd attempt, 5s before the 3rd, then 10s for every attempt after that
public function backoff(): array
{
    return [1, 5, 10];
}
Enter fullscreen mode Exit fullscreen mode

There's also a worker-wide default: php artisan queue:work --tries=3 --backoff=3.

5. Dispatch after the transaction commits

This is the classic "silent" one. You create an order inside DB::transaction() and dispatch SendOrderConfirmation inside the closure. A fast worker can pick the job up before the transaction commits, so the order isn't in the database yet and the job fails with ModelNotFoundException.

It gets worse if the job has public $deleteWhenMissingModels = true;. Laravel then quietly discards the job, by design, and you never hear about it.

Fix it globally per connection:

'redis' => [
    'driver' => 'redis',
    // ...
    'after_commit' => true,
],
Enter fullscreen mode Exit fullscreen mode

Or per dispatch:

SendOrderConfirmation::dispatch($order)->afterCommit();
Enter fullscreen mode Exit fullscreen mode

With after_commit enabled, jobs dispatched inside a transaction that rolls back are discarded too, which is usually what you want. The setting also applies to queued listeners, mailables, notifications and broadcast events.

6. Make Supervisor wait for your longest job

On deploy or restart, Supervisor sends a stop signal and waits stopwaitsecs before force-killing the worker. If that's shorter than your longest job, the job is killed mid-way. It then sits reserved until retry_after passes, and with tries = 1 it ends up failed.

Adapted from the docs:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --timeout=75 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
Enter fullscreen mode Exit fullscreen mode

The rule from the docs: stopwaitsecs must be greater than the runtime of your longest-running job.

7. Restart workers on every deploy, and recycle them

Workers are long-lived processes. They keep running the code they booted with until they're restarted. Add this to every deploy:

php artisan queue:restart
Enter fullscreen mode Exit fullscreen mode

Workers finish their current job and exit gracefully, then Supervisor starts them on the new code. The restart signal is stored in the cache, so a misconfigured cache driver means workers never see it.

Also recycle workers so slow memory growth doesn't build up:

php artisan queue:work --max-time=3600   # exit after an hour
php artisan queue:work --max-jobs=1000   # or after 1000 jobs
Enter fullscreen mode Exit fullscreen mode

One Redis-specific trap: 'block_for' => 0 makes workers block indefinitely waiting for a job, and that also stops them handling SIGTERM until the next job arrives. Use a small positive value like 5 if you set it at all.

8. Keep failed jobs, react to them, prune them

A failed job you can't see is a lost job. Check three things:

Storage. The skeleton's default failed driver is database-uuids, which writes to failed_jobs. Make sure the table migration has run in production, and don't set the driver to null unless you mean it.

A failed() hook for cleanup or user-facing compensation:

use Throwable;

public function failed(?Throwable $exception): void
{
    // mark the export as failed, notify the user, release a reserved slot...
}
Enter fullscreen mode Exit fullscreen mode

One global listener that alerts you, in AppServiceProvider::boot():

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;

Queue::failing(function (JobFailed $event) {
    Log::critical('Queue job failed', [
        'connection' => $event->connectionName,
        'job' => $event->job->resolveName(),
        'error' => $event->exception->getMessage(),
    ]);
});
Enter fullscreen mode Exit fullscreen mode

Then retry with php artisan queue:retry all (or by UUID) once the cause is fixed, and keep the table small. In Laravel 11+ that goes in routes/console.php:

use Illuminate\Support\Facades\Schedule;

Schedule::command('queue:prune-failed --hours=168')->daily();
Schedule::command('queue:prune-batches --hours=48')->daily(); // if you use batches
Enter fullscreen mode Exit fullscreen mode

9. Alert on queue depth, not just failures

Sometimes nothing fails, the backlog just grows: a worker died, Supervisor wasn't enabled after a reboot, or a burst of jobs arrived. Laravel ships queue:monitor, which fires a QueueBusy event when a queue crosses a threshold. Schedule it every minute:

// routes/console.php
Schedule::command('queue:monitor redis:default,redis:emails --max=500')->everyMinute();
Enter fullscreen mode Exit fullscreen mode
// AppServiceProvider::boot()
use Illuminate\Queue\Events\QueueBusy;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;

Event::listen(function (QueueBusy $event) {
    Log::critical("Queue {$event->connection}:{$event->queue} has {$event->size} jobs waiting");
    // or Notification::route('mail', 'ops@yourcompany.com')->notify(...)
});
Enter fullscreen mode Exit fullscreen mode

Pick --max from your normal peak, not a guess: look at a busy day's queue size and alert well above it.

A 2-minute checklist

  • [ ] --timeout (and every job's $timeout) < retry_after, by a clear margin
  • [ ] PCNTL installed on worker servers
  • [ ] tries / retryUntil set for jobs behind rate limits or overlap locks
  • [ ] backoff on anything that talks to an external service
  • [ ] after_commit => true (or ->afterCommit()) where jobs are dispatched in transactions
  • [ ] Supervisor stopwaitsecs > longest job
  • [ ] queue:restart in the deploy script, workers recycled with --max-time / --max-jobs
  • [ ] failed_jobs exists, a Queue::failing alert is set up, pruning is scheduled
  • [ ] queue:monitor scheduled with a real threshold

If you use Horizon, the same ideas apply, but the worker options live in config/horizon.php instead of Supervisor command flags.


I write short, practical notes from running Laravel in production. Follow me here on dev.to to get one every week. Next up: making scheduled tasks safe on multi-server deploys.

Top comments (1)

Collapse
 
harrysstone profile image
Harry Stone •

Point 2 bit us last year. We have a job that fans out a batch of HTTP requests and it kept blowing past $timeout - turns out blocking sockets ignore the job timeout, so we had to set client-side timeouts on the HTTP layer before the timeout actually enforced anything. The retry_after vs --timeout thing is also one of those that only shows up when you scale; we saw a duplicate run where a slow job got picked up by a second worker while the first was still in it, and the fix was just keeping timeout a few seconds under retry_after. Long jobs ended up on their own queue with their own retry_after, exactly like you say.