Why Queues Exist

An HTTP request should do one job: work out what to say, and say it. Anything slow that the user does not need to wait for — email, SMS, thumbnail generation, PDF rendering, third-party API calls, report exports, webhooks — is a candidate for a queue.

// Synchronous — the user waits for the SMTP handshake
public function store(Request $request)
{
    $order = Order::create($request->validated());

    Mail::to($order->email)->send(new OrderConfirmation($order));   // ~800ms
    $this->generateInvoicePdf($order);                              // ~2s
    $this->notifyWarehouseApi($order);                              // ~1.2s

    return redirect()->route('orders.show', $order);                // 4+ seconds later
}

// Queued — the response returns in ~50ms
public function store(Request $request)
{
    $order = Order::create($request->validated());

    SendOrderConfirmation::dispatch($order);
    GenerateInvoicePdf::dispatch($order);
    NotifyWarehouse::dispatch($order);

    return redirect()->route('orders.show', $order);
}

Beyond speed, queues give you retries. If the warehouse API is down, a synchronous call throws a 500 at your customer. A queued job retries three times over the next few minutes and probably succeeds without anyone noticing.

Choosing a Driver

The driver is set by QUEUE_CONNECTION in .env and configured in config/queue.php.

DriverGood forTrade-off
syncLocal development, testsRuns immediately in-process — not a queue at all
databaseSmall to medium apps, no extra infrastructurePolls a table; contention under heavy load
redisThe default choice for productionNeeds Redis; pairs with Horizon
sqsAWS-hosted, very high volumeCosts per request; 15-minute visibility cap
beanstalkdLightweight dedicated queue serverAnother service to run and monitor

The database driver needs a table:

php artisan make:queue-table
php artisan migrate

Start on database. It is genuinely fine up to a few thousand jobs an hour and needs zero extra infrastructure. Move to Redis when you actually feel the contention — the job code does not change.

Creating a Job

php artisan make:job SendOrderConfirmation
<?php

namespace App\Jobs;

use App\Models\Order;
use App\Mail\OrderConfirmation;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

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

    public int $tries = 3;
    public int $timeout = 60;

    public function __construct(public Order $order) {}

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

    public function failed(\Throwable $e): void
    {
        Log::error('Order confirmation failed', [
            'order_id' => $this->order->id,
            'error'    => $e->getMessage(),
        ]);
    }
}

Two details do most of the work here:

  • ShouldQueue is what makes it asynchronous. Remove that interface and the job runs immediately when dispatched.
  • SerializesModels stores only the model’s primary key in the payload and re-fetches it fresh when the worker picks the job up. That keeps payloads tiny and guarantees the worker sees current data.

Because SerializesModels re-fetches by ID, a job whose model was deleted before the worker ran will throw ModelNotFoundException. Set public bool $deleteWhenMissingModels = true; on the job to quietly discard it instead.

Anything you pass to the constructor is serialised into the queue payload, so pass IDs and models — not closures, open file handles, or 5 MB of raw data.

Dispatching Jobs

// Basic
SendOrderConfirmation::dispatch($order);

// Delay it
SendOrderConfirmation::dispatch($order)->delay(now()->addMinutes(10));

// Pick a queue (workers can be told which queues to consume)
SendOrderConfirmation::dispatch($order)->onQueue('emails');

// Pick a connection
SendOrderConfirmation::dispatch($order)->onConnection('redis');

// Only if a condition holds
SendOrderConfirmation::dispatchIf($order->email, $order);
SendOrderConfirmation::dispatchUnless($order->is_guest, $order);

// Wait until the surrounding DB transaction commits
SendOrderConfirmation::dispatch($order)->afterCommit();

// Fire it synchronously, ignoring the queue
SendOrderConfirmation::dispatchSync($order);

The transaction race. If you dispatch inside a DB::transaction(), a fast worker can pick the job up before the transaction commits — and then fail to find the row. Either call ->afterCommit(), or set 'after_commit' => true on the connection in config/queue.php so it is the default everywhere.

Queue names let you prioritise. Give password resets their own queue and tell the worker to drain it first:

php artisan queue:work --queue=high,default,low

Running the Worker

# Long-running process — the normal way
php artisan queue:work

# Process the queue then exit — useful in cron-only environments
php artisan queue:work --stop-when-empty

# Restart workers after a deploy (they load code once at boot)
php artisan queue:restart

# See what is waiting
php artisan queue:monitor redis:default --max=100
CommandBehaviour
queue:workBoots the framework once and stays alive. Fast.
queue:listenReboots the framework per job. Slow, but picks up code changes.

queue:work holds your application code in memory. Deploying new code does nothing until you restart the workers. Add php artisan queue:restart to your deploy script — forgetting this is the single most common queue bug in production.

Retries, Timeouts and Backoff

class NotifyWarehouse implements ShouldQueue
{
    // Attempt at most 5 times
    public int $tries = 5;

    // Kill the job if a single attempt exceeds 120 seconds
    public int $timeout = 120;

    // Stop retrying after 10 minutes regardless of attempt count
    public function retryUntil(): \DateTime
    {
        return now()->addMinutes(10);
    }

    // Exponential-ish backoff between attempts, in seconds
    public function backoff(): array
    {
        return [10, 30, 60, 300];
    }

    // Fail immediately on errors that will never succeed
    public function handle(): void
    {
        try {
            Http::timeout(30)->post($this->endpoint, $this->payload)->throw();
        } catch (RequestException $e) {
            if ($e->response->status() === 422) {
                $this->fail($e);   // bad payload — retrying is pointless
            }
            throw $e;              // anything else — let it retry
        }
    }
}

You can set these globally on the command line too:

php artisan queue:work --tries=3 --timeout=90 --backoff=30 --max-jobs=1000 --max-time=3600

Keep --timeout lower than the driver’s visibility timeout (retry_after in config/queue.php). If retry_after fires first, the job gets handed to a second worker while the first is still running it — and you send the email twice.

Handling Failed Jobs

A job that exhausts its attempts lands in the failed_jobs table with its full payload and exception trace.

php artisan make:queue-failed-table
php artisan migrate

php artisan queue:failed          # list them
php artisan queue:retry 5         # retry job with id 5
php artisan queue:retry all       # retry everything
php artisan queue:forget 5        # delete one
php artisan queue:flush           # clear the table

Hook the global failure event to alert yourself rather than discovering it a week later:

// app/Providers/AppServiceProvider.php — boot()
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobFailed;

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

Batches and Chains

A chain runs jobs strictly in order, and stops if any link fails:

use Illuminate\Support\Facades\Bus;

Bus::chain([
    new ProcessPayment($order),
    new GenerateInvoicePdf($order),
    new SendOrderConfirmation($order),
])->catch(function (\Throwable $e) {
    Log::error('Order pipeline broke', ['error' => $e->getMessage()]);
})->dispatch();

A batch runs jobs in parallel and gives you a completion callback and live progress. Perfect for bulk imports:

$batch = Bus::batch(
    $rows->chunk(500)->map(fn ($chunk) => new ImportCustomers($chunk))
)->name('Customer import')
 ->allowFailures()
 ->then(fn (Batch $b)     => Notification::send($user, new ImportFinished($b)))
 ->catch(fn (Batch $b, $e) => Log::error('Import failed', ['id' => $b->id]))
 ->finally(fn (Batch $b)   => Log::info('Batch done', ['id' => $b->id]))
 ->dispatch();

// Poll progress from a controller
$batch = Bus::findBatch($batchId);
$batch->progress();      // 0-100
$batch->processedJobs();
$batch->failedJobs;

Batches need their own table: php artisan make:queue-batches-table.

Production Setup With Supervisor

A worker started by hand dies with your SSH session. Supervisor keeps it alive and restarts it if it crashes.

; /etc/supervisor/conf.d/laravel-worker.conf
[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
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*
sudo supervisorctl status

--max-time=3600 makes each worker exit hourly so Supervisor restarts it — a cheap guard against slow memory leaks in long-running PHP processes.

On Redis, install Laravel Horizon (composer require laravel/horizon). You get a dashboard with throughput, wait times, failed jobs and per-queue metrics, plus auto-balancing workers — and you configure everything in config/horizon.php instead of Supervisor config.

Pitfalls Worth Knowing

  • Jobs must be idempotent. A worker can crash after doing the work but before marking the job complete, so it runs again. Guard side effects with a check or a unique constraint.
  • Use ShouldBeUnique to stop duplicate jobs queueing for the same record — useful for “rebuild this report” triggered by rapid edits.
  • No request context. There is no session, no auth()->user() and no current URL inside a job. Pass what you need through the constructor.
  • Queue the mail, not the job. Mail::to(...)->queue(...) and ShouldQueue on a Notification already queue themselves — wrapping them in another job queues twice.
  • Test with the fake. Queue::fake() plus Queue::assertPushed(SendOrderConfirmation::class) tests dispatch without running the job.
use Illuminate\Contracts\Queue\ShouldBeUnique;

class RebuildReport implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 300;   // lock expires after 5 minutes

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

Get the driver, the restart step and idempotency right, and queues will quietly absorb most of your slow work for years without attention.