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();SerializesModelsstores the model identifier and re-fetches it on the worker, so the job sees current data - and fails if the row was deleted. UsedeleteWhenMissingModelswhen 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;WithoutOverlappingprevents 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| Driver | Durability | Notes |
|---|---|---|
sync | None | Immediate execution - tests and local only |
database | In your DB | Simple, but adds write load to the primary |
redis | Redis persistence | Fast; configure block_for for atomic popping |
sqs | Managed, at-least-once | Visibility 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>&1onOneServer()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.Related
The service container, providers and facades Deployment and production hardening
Last refreshed 2026-09-18.