Queues, jobs and scheduled tasks

Queue drivers, dispatching and chaining jobs, retries and backoff, failed-job handling, Horizon and writing a safe schedule.

Writing and dispatching jobs

class SendInvoice implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public int $timeout = 120;
    public array $backoff = [10, 30, 120, 600];
    public bool $failOnTimeout = true;

    public function __construct(public Order $order) {}

    public function handle(InvoiceRenderer $renderer): void
    {
        $pdf = $renderer->render($this->order);
        Mail::to($this->order->email)->send(new InvoiceMail($pdf));
    }

    public function middleware(): array
    {
        return [(new WithoutOverlapping($this->order->id))->expireAfter(300)];
    }

    public function uniqueId(): string
    {
        return 'invoice-'.$this->order->id;
    }
}

SendInvoice::dispatch($order)->onQueue('mail');
SendInvoice::dispatch($order)->delay(now()->addMinutes(5));
Bus::chain([new ChargeOrder($order), new SendInvoice($order)])
    ->catch(fn (Throwable $e) => report($e))
    ->dispatch();
  • SerializesModels stores the model identifier and re-fetches it on the worker, so the job sees current data - and fails if the row was deleted. Use deleteWhenMissingModels when that is expected.
  • Every job must be idempotent: a worker can crash after the side effect but before the acknowledgement, so the job runs again.
  • A unique job (ShouldBeUnique) prevents duplicates from being queued at all; WithoutOverlapping prevents two workers running the same job key at the same time.
⚠️
Deploying new job class code while old workers are still running is the classic queue outage. Restart workers on deploy (php artisan queue:restart) and keep the payload compatible for one release, or in-flight jobs fail on deserialisation.

Running the queues

php artisan queue:work redis --queue=high,default,mail --tries=5 --max-time=3600
php artisan queue:failed
php artisan queue:retry all
php artisan queue:monitor redis:default,redis:mail --max=1000
php artisan horizon        # supervisor, metrics and UI
DriverDurabilityNotes
syncNoneImmediate execution - tests and local only
databaseIn your DBSimple, but adds write load to the primary
redisRedis persistenceFast; configure block_for for atomic popping
sqsManaged, at-least-onceVisibility timeout must exceed the job timeout

Run workers under Supervisor or Horizon with --max-time so a leaked resource is reclaimed. One long-running worker accumulates memory and stale container state; recycling hourly is cheaper than debugging it.

Scheduled tasks

// routes/console.php (Laravel 11+)
Schedule::command('reports:daily')->dailyAt('02:00')->timezone('UTC');
Schedule::job(new RefreshCatalog)->hourly()->withoutOverlapping();
Schedule::call(fn () => Cache::forget('catalog'))->everyFiveMinutes()
    ->runInBackground()
    ->onOneServer()
    ->evenInMaintenanceMode();

// one cron entry drives everything
// * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
  • onOneServer() requires a shared cache driver; without it every instance runs the task.
  • Add ->withoutOverlapping() to anything that can outlast its interval.
  • Set an explicit timezone - the default follows app.timezone, which is not always UTC in production.

FAQ

How many workers do I need?
Start with the number of concurrent jobs your database and downstream APIs tolerate, not the number of CPU cores. Measure queue latency with Horizon, then scale per queue so a slow mail provider cannot starve time-critical work.
Where do failed jobs go?
Into the failed_jobs table, with the payload and exception. Alert on growth, inspect with queue:failed, fix the cause and queue:retry - but only after confirming the job is idempotent.

The service container, providers and facades Deployment and production hardening

Last refreshed 2026-09-18.