Sending an email, generating a PDF, calling a slow third-party API, resizing an uploaded image — none of these belong in the request-response cycle. A user shouldn’t wait three seconds for a page to load just because your app also emailed them a receipt in the background. Laravel’s queue system lets you push that work onto a background worker so requests stay fast, and it does so with a consistent API regardless of which queue backend you use.

Why Queues Matter

Every synchronous side effect in a controller is a tax on your response time and a liability on your uptime. If the mail server is slow, your checkout page is slow. If a third-party API is down, your registration form throws a 500. Queueing that work means the request finishes as soon as the job is dispatched, and the actual work happens asynchronously, with retries, backoff, and failure handling built in.

Choosing a Queue Driver

Laravel supports several drivers out of the box, configured in config/queue.php via the QUEUE_CONNECTION environment variable. For anything beyond local development, Redis is the pragmatic default — fast, supports delayed jobs natively, and pairs directly with Horizon for monitoring.

composer require predis/predis
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Avoid the database driver in production once you have any meaningful throughput — polling a table for pending jobs adds load that Redis’s blocking pop operations avoid entirely.

Creating and Dispatching Jobs

Generate a job class with Artisan:

php artisan make:job ProcessPodcast
class ProcessPodcast implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Podcast $podcast,
    ) {}

    public function handle(AudioProcessor $processor): void
    {
        $processor->transcode($this->podcast);
        $processor->generateWaveform($this->podcast);

        $this->podcast->update(['status' => 'ready']);
    }
}

Dispatch it from anywhere in your application:

ProcessPodcast::dispatch($podcast);

// Delay execution
ProcessPodcast::dispatch($podcast)->delay(now()->addMinutes(10));

// Target a specific queue
ProcessPodcast::dispatch($podcast)->onQueue('media');

Because $podcast is an Eloquent model, Laravel serializes only its identifier and re-fetches a fresh instance when the job runs — you’re never shipping stale, serialized model state to the queue.

Retries, Backoff, and Failed Jobs

Transient failures — a timeout calling an external API, a momentary lock on a database row — shouldn’t kill a job on the first attempt. Configure retry behavior directly on the job:

class ProcessPodcast implements ShouldQueue
{
    public int $tries = 5;

    public function backoff(): array
    {
        return [10, 30, 60, 300, 900];
    }

    public function failed(Throwable $exception): void
    {
        $this->podcast->update(['status' => 'failed']);

        Log::error('Podcast processing failed permanently', [
            'podcast_id' => $this->podcast->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

The backoff() array gives each retry a longer delay than the last, so a struggling downstream service gets breathing room instead of being hammered five times a second. failed() runs only after every retry attempt is exhausted — use it to notify someone or flag the record, never to retry manually.

Inspect and manage failed jobs from the CLI:

php artisan queue:failed
php artisan queue:retry 5
php artisan queue:retry all
php artisan queue:forget 5

Job Middleware and Rate Limiting

Job middleware wraps handle() the same way HTTP middleware wraps a request — useful for rate limiting calls to a third-party API across every job that touches it:

public function middleware(): array
{
    return [new RateLimited('podcast-transcoding-api')];
}
RateLimiter::for('podcast-transcoding-api', function () {
    return Limit::perMinute(50);
});

Jobs that exceed the limit are automatically released back onto the queue to run later, instead of failing outright.

Running the Queue Worker

php artisan queue:work starts a long-running process that pulls and processes jobs. Never use queue:listen in production — it reboots the framework on every job, which is far slower.

php artisan queue:work redis --queue=media,default --tries=3 --timeout=90

A worker process will eventually die — a deploy, a server restart, an out-of-memory kill. Keep it alive with Supervisor:

[program:podcast-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --queue=media,default --sleep=3 --tries=3
autostart=true
autorestart=true
numprocs=3
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log

numprocs=3 runs three worker processes concurrently — tune this to your queue’s throughput needs, not just CPU count, since most job time is spent waiting on I/O.

Workers cache your application’s code in memory when they boot. After deploying new code, always run php artisan queue:restart — it signals every worker to finish its current job and exit gracefully, so Supervisor spins up fresh processes running the new code.

Job Batching

When you need to run a group of jobs and react once they’re all finished — say, processing a bulk CSV import — batches give you that without hand-rolled counters:

$batch = Bus::batch([
    new ImportRow($row1),
    new ImportRow($row2),
    new ImportRow($row3),
])->then(function (Batch $batch) {
    // All jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
    // First failure detected
})->finally(function (Batch $batch) {
    // Runs regardless of success or failure
})->dispatch();

Check progress at any time via $batch->progress(), $batch->processedJobs(), or $batch->finished() — handy for a progress bar backed by polling.

Monitoring with Horizon

If you’re on Redis, Horizon gives you a dashboard for queue throughput, job runtime, failed jobs, and worker load — visibility you’d otherwise have to build yourself.

composer require laravel/horizon
php artisan horizon:install
php artisan horizon

Define worker pools declaratively in config/horizon.php instead of hand-writing Supervisor configs:

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['media', 'default'],
            'balance' => 'auto',
            'maxProcesses' => 10,
            'tries' => 3,
        ],
    ],
],

balance => auto lets Horizon shift worker processes between queues based on load — the media queue gets more workers during a traffic spike without you touching a config file.

Checklist Before Going to Production

  • QUEUE_CONNECTION set to redis (or another production-appropriate driver), never sync or database
  • Every queued job defines $tries and backoff() explicitly — don’t rely on framework defaults for anything that calls an external service
  • failed() handlers log or alert — a silently failed job is a silently broken feature
  • Supervisor (or Horizon) keeps workers alive across restarts and deploys
  • php artisan queue:restart runs as part of every deploy script
  • Long-running jobs set an explicit --timeout shorter than your process manager’s kill timeout

Queues turn “slow and fragile” into “fast and resilient” for anything that doesn’t need to block the response. The upfront cost — a worker process, a bit of monitoring — pays for itself the first time a third-party API has a bad day and your users never notice.