All Articles
24 Jul 2026 7 min read 1 views
Laravel

Laravel Queues & Job Processing: The Complete Guide

Master Laravel queues — drivers, job dispatching, delayed jobs, batching, chaining, retries, rate limiting, Horizon, and running workers in production.

Tushar Modi.
Tushar Modi.
July 24, 2026 · Jaipur, India
7 min 1
Category Laravel
Published Jul 24, 2026
Read 7 min
Views 1
Updated Jul 24, 2026
Laravel Queues & Job Processing: The Complete Guide

Laravel Queues & Job Processing: The Complete Guide

If a user is waiting on a response while your app sends an email, calls a third-party API, or generates a PDF, you're doing it wrong. Queues exist to move slow work off the request cycle entirely — the user gets an instant response, and the actual work happens in the background. Here's everything from dispatching your first job to running workers reliably in production.

Table of Contents

  1. Why Queues Exist
  2. Queue Drivers and Which One to Pick
  3. Creating and Dispatching a Job
  4. Delayed Jobs
  5. Job Chaining
  6. Job Batching
  7. Retries, Backoff, and Failed Jobs
  8. Rate Limiting and Unique Jobs
  9. Monitoring with Horizon
  10. Running Workers in Production
  11. Common Mistakes
  12. Wrapping Up

1. Why Queues Exist

Any request-response cycle that includes slow work — sending an email, calling a payment gateway, resizing an uploaded image, generating a report — makes the user wait for something they don't need to wait for. Queues decouple "the thing that has to happen" from "the thing the user is watching happen." The HTTP request finishes fast, and a separate worker process picks up the actual job whenever it can.

This isn't just a performance trick — it's also a reliability boundary. If a third-party API is down when a job runs, the job can retry on its own schedule instead of the whole request failing in front of a user.

2. Queue Drivers and Which One to Pick

Laravel supports several queue backends, configured in config/queue.php:

  • sync — runs jobs immediately, inline, no actual queueing. Useful only for local development/testing, never for production.
  • database — stores jobs in a database table. Simple to set up, fine for low-to-moderate volume, but adds load to your primary database under heavy traffic.
  • redis — the most common production choice. Fast, supports delayed jobs and job batching natively, and doesn't compete with your app's database for I/O.
  • sqs — AWS's managed queue service. Worth it if you're already deep in AWS infrastructure and want the queue itself to be someone else's uptime problem.

For most production Laravel apps, Redis is the default recommendation unless you have a specific reason to reach for SQS (already AWS-native infrastructure) or need the simplicity of database for a low-traffic app that doesn't want another moving part.


env

QUEUE_CONNECTION=redis

3. Creating and Dispatching a Job

Generate a job class:


bash

php artisan make:job SendWelcomeEmail


php

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

    public function __construct(public User $user) {}

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

Dispatch it from anywhere in your app:


php

SendWelcomeEmail::dispatch($user);

// Dispatch to a specific named queue
SendWelcomeEmail::dispatch($user)->onQueue('emails');

// Dispatch to a specific connection (if you run more than one)
SendWelcomeEmail::dispatch($user)->onConnection('redis');

Because the job constructor takes an Eloquent model directly, Laravel serializes just the model's ID and re-fetches a fresh instance from the database when the job actually runs — not a stale snapshot from dispatch time.

4. Delayed Jobs

Push a job to run later instead of as soon as a worker picks it up — useful for reminder emails, trial-expiry notifications, or scheduled follow-ups:


php

SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10));

// Or delay based on business logic
SendTrialEndingReminder::dispatch($user)->delay($user->trial_ends_at->subDay());

5. Job Chaining

Chain jobs so each one only runs after the previous one succeeds — useful for a multi-step pipeline where order matters:


php

Bus::chain([
    new ProcessUploadedVideo($upload),
    new GenerateThumbnail($upload),
    new NotifyUserVideoReady($upload),
])->dispatch();

If any job in the chain fails, the remaining jobs in that chain are automatically skipped — you don't get a thumbnail generated for a video that failed to process.

6. Job Batching

Batching is different from chaining — it runs many jobs in parallel and lets you track overall progress, with a callback once everything finishes:


php

$batch = Bus::batch([
    new ImportUsersChunk($chunk1),
    new ImportUsersChunk($chunk2),
    new ImportUsersChunk($chunk3),
])->then(function (Batch $batch) {
    // All jobs completed successfully
})->catch(function (Batch $batch, Throwable $e) {
    // First job failure in the batch
})->finally(function (Batch $batch) {
    // Runs regardless of success or failure
})->dispatch();

// Check progress anywhere
$batch->progress(); // percentage complete

This is the right tool for bulk operations — importing a large CSV in chunks, sending a campaign to thousands of users, reprocessing a big dataset — where you want parallelism plus a single point to check overall status.

7. Retries, Backoff, and Failed Jobs

Define how many times a job should retry and how long to wait between attempts:


php

class SendWelcomeEmail implements ShouldQueue
{
    public $tries = 3;

    public function backoff(): array
    {
        return [10, 30, 60]; // seconds: wait 10s, then 30s, then 60s
    }

    public function failed(Throwable $exception): void
    {
        // Notify yourself, log to an error tracker, alert on Slack — whatever fits
        Log::error('Welcome email permanently failed', ['user' => $this->user->id]);
    }
}

Failed jobs (after exhausting all retries) land in the failed_jobs table by default. Inspect and retry them:


bash

php artisan queue:failed              # list failed jobs
php artisan queue:retry <id>          # retry a specific failed job
php artisan queue:retry all           # retry everything
php artisan queue:forget <id>         # delete a specific failed job record

8. Rate Limiting and Unique Jobs

Throttle how often a job type runs — useful when a job hits a rate-limited third-party API:


php

public function middleware(): array
{
    return [new RateLimited('third-party-api')];
}

Prevent the same logical job from being queued twice while one is already pending — critical for anything triggered by user action that could fire more than once (double-clicking a button, a webhook retry):


php

class ProcessPayment implements ShouldQueue, ShouldBeUnique
{
    public function uniqueId(): string
    {
        return $this->order->id;
    }

    public $uniqueFor = 3600; // seconds the uniqueness lock holds
}

9. Monitoring with Horizon

For Redis-backed queues, Laravel Horizon gives you a dashboard for queue throughput, job runtime, failed job details, and per-queue metrics — without which you're flying blind on what your workers are actually doing.


bash

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

Configure queue priorities and worker counts in config/horizon.php:


php

'environments' => [
    'production' => [
        'supervisor-1' => [
            'connection' => 'redis',
            'queue' => ['high', 'default', 'low'],
            'balance' => 'auto',
            'maxProcesses' => 10,
        ],
    ],
],

balance => 'auto' lets Horizon dynamically shift worker processes toward whichever queue has the most backlog, instead of a fixed split that wastes capacity on quiet queues.

10. Running Workers in Production

php artisan queue:work needs to run continuously in production — and needs to restart itself if it crashes. Use Supervisor to manage this:


ini

; /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/myapp/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=deployer
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/myapp/storage/logs/worker.log
stopwaitsecs=3600


bash

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-worker:*

--max-time=3600 restarts each worker process every hour — this matters because long-running PHP workers slowly accumulate memory, and a scheduled restart is cheaper than debugging a memory leak. Also remember: every deploy needs php artisan queue:restart, or your workers keep running the old code from before the deploy, silently, until they eventually restart on their own.

11. Common Mistakes

  • Using sync in production — defeats the entire purpose; jobs run inline and block the request exactly like not using a queue at all
  • Forgetting queue:restart after deploys — workers keep executing stale code for however long they stay alive
  • No failed() handling — a job silently failing after all retries, with nobody ever finding out, is worse than no retry logic at all
  • Passing huge payloads into job constructors — pass an ID and re-fetch inside handle(), not a huge array or collection serialized into the queue payload
  • Not setting --max-time or --max-jobs — long-lived workers can leak memory over days; scheduled restarts are cheap insurance
  • Treating Horizon as optional — without it, you find out about a stuck queue when a customer complains their email never arrived, not before

12. Wrapping Up

Queues are one of those Laravel features that quietly separates apps that feel fast from apps that don't. The setup cost is small — a driver, a job class, a worker process — and the payoff compounds every time you add one more slow operation that no longer blocks a user's request. Start by moving your slowest current request — usually an email or a third-party API call — into a job, and build from there.