Web Development 5-8 minutes

Queues and Jobs in Laravel 13: From Your First Dispatch to Horizon in Production

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Queues and Jobs in Laravel 13: From Your First Dispatch to Horizon in Production

A confirmation email that takes two seconds inside the request is the difference between a satisfied customer and one who abandons the checkout. Laravel 13 queues solve that problem by moving heavy work to the background: this guide takes you from your first dispatch with the database driver to running Horizon in production.

Why Your Laravel App Needs Queues

The Problem with Synchronous Work Inside the Request

Every time your controller sends an email, generates a thumbnail, or calls an external webhook, the HTTP request waits for that task to finish before returning a response. Sending mail through SMTP can add a second or two; generating an image or querying a third-party API takes even longer. The user perceives that wait as slowness, and if the external service is slow or down, your application can return a 500 error for something that was not really the page's job.

What a Queue Solves: Availability, Retries, and Scale

A queue decouples the request from the heavy work: the controller queues the task, responds instantly, and a worker process executes it in the background. That improves availability, because the request no longer depends on an external service; it adds automatic retries, because a failed job can be attempted again; and it makes scaling easier, because you can run as many workers as you need without touching your application code.

How Queues Work in Laravel 13

Drivers: database, Redis, SQS, Beanstalkd, and sync

Laravel 13 ships with queue connections for several backends: database stores jobs in a MySQL or SQLite table, Redis keeps them in memory for maximum speed, Amazon SQS offers a fully managed cloud queue, Beanstalkd is a lightweight queue server, and the sync driver runs jobs immediately in the same process, which is handy for development and testing. The active connection is defined with the QUEUE_CONNECTION variable in your .env file.

The jobs Table and the dispatch → worker Flow

When you dispatch a job, Laravel serializes the class and its data into the jobs table with a pending state. The worker, a PHP process started with the artisan queue:work command, reads that table, reserves the first available job, and runs its handle method. If it succeeds, the job is deleted from the queue; if it throws an exception, it is released for a retry or moved to the failed jobs table once attempts run out. In a fresh Laravel 13 project, the jobs table migration is included by default; if your application is older and lacks it, you create it with php artisan make:queue-table.

Your First Job: Processing an Order

php artisan make:job and the ShouldQueue Interface

Let's build the classic example: processing an order that was just paid. The first step is generating the job class:

php artisan make:job ProcessOrder

The command creates a class in App\Jobs. For Laravel to treat it as asynchronous work, it must implement the ShouldQueue interface. The job receives the order model and runs the heavy steps in its handle method:

<?php

namespace App\Jobs;

use App\Models\Order;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Order $order,
    ) {}

    public function handle(): void
    {
        // Send the confirmation email
        Mail::to($this->order->email)->send(new OrderConfirmation($this->order));

        // Generate the invoice as a PDF
        $pdf = InvoicePdf::generate($this->order);

        // Notify the warehouse via webhook
        Http::post(config('services.warehouse.webhook'), $this->order->toArray());
    }
}

Queuing with dispatch() and Delaying with delay

From the controller, queuing the job is a single line: ProcessOrder::dispatch($order). The request returns its response immediately and the worker processes the order in the background. If you want the work to wait a few minutes before starting, for example to give the payment gateway some margin, add a delay:

ProcessOrder::dispatch($order)->delay(now()->addMinutes(5));

Running the Worker in Development: queue:work

For jobs to be processed you need a running worker. In development, php artisan queue:work is enough: the process stays in the foreground reading the queue and executing jobs as they arrive. Instead of opening several terminals, you can run it in the background or use the sync driver locally so jobs run instantly and you do not have to maintain an extra process.

From database to Redis: When to Switch Drivers

Redis-Free Queues with the database Driver

Many Laravel 13 applications run perfectly fine without Redis: the database driver stores jobs in the corresponding table and, with a worker behind it, performance is more than enough for tens of thousands of jobs per day. It is the simplest option for a small server, because it adds no extra service to install or monitor.

Why Redis Wins at Volume

When the number of jobs grows, Redis brings two advantages: reads and writes are much faster because everything lives in memory, and it enables the Horizon dashboard, which only works with Redis connections. The switch is minimal: install a client (predis or phpredis) and set QUEUE_CONNECTION=redis in your environment. Nothing else in your code changes.

Retries, Timeouts, and Failed Jobs

tries, backoff, and maxExceptions

A job can fail because an external service is down or because of a transient error. Laravel retries automatically according to the class properties: tries defines the maximum number of attempts, backoff the seconds to wait between attempts (it accepts an array for progressive waits), and timeout limits how long the worker spends on the job before killing it:

public $tries = 3;
public $backoff = [10, 60, 300];
public $timeout = 120;
public $maxExceptions = 2;

With that configuration, a failing job will be retried after 10 seconds, then 60, and finally 300. maxExceptions lets a job survive several individual failures without burning through all its attempts when only a few of them were one-off exceptions.

The failed_jobs Table and queue:retry

When a job exhausts its attempts, Laravel moves it to the failed_jobs table and records the reason. It does not stay there forever: you can list failures with php artisan queue:failed, retry them all with php artisan queue:retry all, and delete a specific one with queue:forget. That is the manual recovery loop that stops work from being lost silently.

Job Batching and Chains

Bus::batch for Processing Batches

When an operation splits into several independent tasks, you can group them into a batch with Bus::batch. Laravel creates a record in the job_batches table and lets you run callbacks when the batch finishes, fails, or is cancelled:

use Illuminate\Support\Facades\Bus;

Bus::batch([
    new ProcessOrder($order),
    new SendInvoiceEmail($order),
    new NotifyWarehouse($order),
])->then(function () use ($order) {
    $order->markAsProcessed();
})->dispatch();

Chaining Jobs with ->chain()

If the tasks must run in strict order, one after another, use chains. Chaining jobs guarantees that the next one does not start until the previous one finishes successfully; if one fails, the rest of the chain is not executed:

ProcessOrder::dispatch($order)->chain([
    new SendInvoiceEmail($order),
    new NotifyWarehouse($order),
]);

Unique Jobs and Rate Limiting

Some operations must not be duplicated: reindexing a specific model, syncing an entity with an external service, or regenerating a cache. That is what the ShouldBeUnique interface is for: it prevents a second identical job from being queued while the first is still pending or processing. And if your job calls a third-party API with a request limit, Laravel's RateLimited middleware pauses processing when you hit the limit instead of failing noisily.

Horizon: The Redis Queue Dashboard for Production

Declarative Supervisors and Real-Time Metrics

Horizon is Laravel's official administration panel for Redis queues. Instead of launching workers by hand, you define supervisors in its configuration file: which queues they serve, how many processes minimum and maximum, and how to balance the work. The dashboard shows queued, processing, and completed jobs in real time, with performance metrics that tell you whether you need more workers before queues start piling up.

Handling Failures from the Dashboard

Failed jobs appear in Horizon with their full exception and the payload that caused them. From the panel you can retry or delete them without touching the terminal, turning recovery into a one-click task. That said, Horizon requires Redis, so it is the final push to leave the database driver behind in production.

Workers in Production: supervisord and Octane

In production, a worker cannot depend on an open terminal. The standard way is to manage it with supervisord, which keeps it alive and restarts it if it dies. A typical program launches several queue:work processes with retries configured:

[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
numprocs=4
redirect_stderr=true

If you use Laravel Octane, version 13 includes worker lifecycle improvements, such as the new WorkerStopReason that reports precisely why a process stopped. Either way, the idea is the same: workers should be supervised long-running processes, not commands launched by hand.

Conclusion

Laravel 13 queues are the tool that separates an application that responds instantly from one that stalls on every email or webhook. Start with the database driver and a single job, master retries and batching, and when volume demands it, move to Redis with Horizon to monitor everything from one panel. If you want to keep going deeper into Laravel 13, check out the rest of the framework guides on this blog.

Categories