All Articles
13 Jul 2026 14 min read 7 views
Laravel

Laravel Queues & Horizon — Complete Guide (2026)

Complete Laravel queues and Horizon guide — Redis setup, queue topology, Bus::bulk(), job batching, chains, rate limiting, Horizon config, Supervisor, memory leak prevention, production checklist.

Tushar Modi.
Tushar Modi.
July 13, 2026 · Jaipur, India
14 min 7
Category Laravel
Published Jul 13, 2026
Read 14 min
Views 7
Updated Jul 14, 2026
Laravel Queues & Horizon — Complete Guide (2026)

Laravel Queues & Horizon — Complete Guide (2026)

Every web request has a clock ticking against it.

Send a welcome email inside the request cycle and the user waits. Generate a PDF inside the request cycle and the user waits. Call a third-party API inside the request cycle and the user waits. Do any of these inside the request lifecycle and you are making the user sit through work that has nothing to do with their response.

Queues flip this completely. Instead of doing the heavy work immediately, you push a job onto a queue and return the response right away. A separate worker process picks it up and executes it completely outside the user's request.

I have built queue systems on SaaS platforms, e-commerce APIs, and GoRiderss. This guide covers everything — from your first job to production-grade Horizon configuration, Bus::bulk(), batching, chains, and failure handling.

The Mental Model


User Request
    ↓
Controller dispatches Job
    ↓
Redis stores the job payload
    ↓
Response returned immediately (milliseconds)
    ↓ (background)
Worker picks up job
    ↓
Job executes
    ↓
Done — user never waited

Three components:

  • Job — the work to be done (a PHP class)
  • Queue — the list of pending jobs (Redis, database, SQS)
  • Worker — the background process that executes jobs

That is the entire system. Everything else is configuration and strategy.

Queue Driver — Choose Redis in Production

Laravel supports several queue drivers. The choice matters more than most tutorials admit.


php

// config/queue.php — driver options
'connections' => [
    'sync'     => ['driver' => 'sync'],      // dev only — runs immediately
    'database' => ['driver' => 'database'],  // dev/small — slow under load
    'redis'    => ['driver' => 'redis'],     // production — fast, Horizon-compatible
    'sqs'      => ['driver' => 'sqs'],       // Vapor/serverless — managed, no Horizon
    'cloud'    => ['driver' => 'cloud'],     // Laravel Cloud — new in 13.15
],

For production, Redis is the right choice. It's in-memory, extremely fast, and integrates perfectly with Laravel Horizon. Kamruzzaman Polash

Driver comparison:

DriverDevelopmentProductionHorizonNotessync✅ Simple❌ Never❌Runs jobs immediately, no queuedatabase✅ Good⚠️ Small apps❌Table locks at high volumeredis✅✅ Best✅Fast, in-memory, recommendedsqs❌✅ Serverless❌AWS managed, infinite scalecloud❌✅ Cloud✅Laravel Cloud, managed


env

# Development
QUEUE_CONNECTION=database

# Production
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=null

Creating Your First Job


bash

php artisan make:job SendWelcomeEmail


php

// app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;

use App\Models\User;
use App\Mail\WelcomeEmail;
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 SendWelcomeEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    // Job configuration
    public int $tries   = 3;           // retry 3 times on failure
    public int $timeout = 30;          // kill after 30 seconds
    public int $backoff = 60;          // wait 60 seconds before retry
    public bool $failOnTimeout = true; // record as failed, not silent retry

    public function __construct(
        public readonly int $userId  // pass ID, not model — safer serialization
    ) {}

    public function handle(): void
    {
        $user = User::findOrFail($this->userId);
        Mail::to($user->email)->send(new WelcomeEmail($user));
    }

    public function failed(\Throwable $e): void
    {
        // Called when all retries exhausted
        \Log::error('WelcomeEmail failed permanently', [
            'user_id' => $this->userId,
            'error'   => $e->getMessage(),
        ]);
    }
}

Dispatch it:


php

// In your controller — after user registration
public function register(RegisterRequest $request): JsonResponse
{
    $user = User::create($request->validated());

    // Dispatch to queue — returns immediately
    SendWelcomeEmail::dispatch($user->id);

    // Or with delay
    SendWelcomeEmail::dispatch($user->id)->delay(now()->addMinutes(5));

    // Or on a specific queue
    SendWelcomeEmail::dispatch($user->id)->onQueue('emails');

    return response()->json(['message' => 'Registration successful'], 201);
}

Queue Topology — The Mistake Everyone Makes

The moment you have a slow PDF-generation job sitting ahead of a time-sensitive webhook-processing job, your users feel the latency. The core problem is priority inversion. Without explicit queue topology, every job competes for the same worker pool. Heavy jobs starve lightweight ones. RichDynamix

Wrong — everything on one queue:


php

// ProcessPayment, SendEmail, GenerateReport all compete
// One slow report blocks payment processing
dispatch(new ProcessPayment($order));    // critical — must be fast
dispatch(new SendEmail($user));          // important — should be fast
dispatch(new GenerateReport($data));    // heavy — can wait

Right — separate queues by latency contract:


php

// AppServiceProvider — Laravel 13 Queue::route()
Queue::route([
    ProcessPayment::class    => 'payments@redis',  // critical: <1s
    ProcessRefund::class     => 'payments@redis',
    SendEmail::class         => 'emails@redis',    // important: <5s
    SendNotification::class  => 'emails@redis',
    GenerateReport::class    => 'reports@redis',   // heavy: <5min
    GenerateInvoice::class   => 'reports@redis',
    SyncAnalytics::class     => 'low@redis',       // background: whenever
    CleanupTempFiles::class  => 'low@redis',
]);

Design your queue topology by separating jobs into purpose-built queues so high-volume work doesn't starve critical jobs. RichDynamix

Job Design Principles

1. Pass IDs, Not Models


php

// WRONG — serializes entire model, can balloon in size
public function __construct(public User $user) {}

// RIGHT — pass ID, fetch in handle()
public function __construct(public int $userId) {}

public function handle(): void
{
    $user = User::findOrFail($this->userId);
    // ...
}

2. Make Jobs Idempotent

Always design long-running jobs to be idempotent so a retry can safely resume. RichDynamix

A job that runs twice should produce the same result as a job that runs once.


php

class SyncUserToExternalCrm implements ShouldQueue
{
    public function handle(): void
    {
        $user = User::findOrFail($this->userId);

        // Idempotent: upsert by external ID
        // Running twice = same result as running once
        $this->crmService->upsert([
            'external_id' => $user->id,
            'email'       => $user->email,
            'name'        => $user->name,
            'updated_at'  => now()->toISOString(),
        ]);
    }
}

3. Exponential Backoff


php

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

    // Exponential backoff: 1s, 5s, 10s, 30s, 60s
    public array $backoff = [1, 5, 10, 30, 60];

    // Or use the method for dynamic backoff
    public function backoff(): array
    {
        return [1, 5, 10, 30, 60];
    }
}

4. Prevent Overlapping


php

use Illuminate\Queue\Middleware\WithoutOverlapping;

class SyncInventory implements ShouldQueue
{
    // Only one instance of this job per product can run at a time
    public function middleware(): array
    {
        return [new WithoutOverlapping($this->productId)];
    }
}

Bus::bulk() — Laravel 13.13

Before: dispatch in a loop. N dispatches, N round trips. After: Bus::bulk() dispatches all jobs in one call. RichDynamix


php

// Before — 500 jobs = 500 queue round trips
foreach ($orders as $order) {
    dispatch(new ProcessOrder($order->id));
}

// After — 500 jobs = 1 round trip
Bus::bulk(
    collect($orders)
        ->map(fn ($order) => new ProcessOrder($order->id))
        ->toArray(),
    queue:      'orders',
    connection: 'redis'
);

// Mixed job types — works fine
Bus::bulk([
    new SendWelcomeEmail($user->id),
    new CreateDefaultSettings($user->id),
    new SyncToExternalCrm($user->id),
    new NotifyAdminOfNewUser($user->id),
]);

Job Batching — Track Progress Across Multiple Jobs


php

use Illuminate\Support\Facades\Bus;

public function processImport(array $rows): JsonResponse
{
    $batch = Bus::batch(
        collect($rows)->map(fn ($row) => new ProcessImportRow($row))
    )
    ->name('Import: ' . date('Y-m-d H:i'))
    ->then(function (Batch $batch) {
        // All jobs completed successfully
        ImportCompleted::dispatch($batch->id);
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        // First job failure — batch continues unless you cancel it
        \Log::error('Batch job failed', ['batch' => $batch->id, 'error' => $e->getMessage()]);
    })
    ->finally(function (Batch $batch) {
        // Batch finished — success or failure
        \Log::info('Batch done', [
            'total'      => $batch->totalJobs,
            'processed'  => $batch->processedJobs(),
            'failed'     => $batch->failedJobs,
        ]);
    })
    ->allowFailures()  // continue processing even if some jobs fail
    ->onQueue('imports')
    ->dispatch();

    return response()->json(['batch_id' => $batch->id]);
}

// Check batch progress
public function batchStatus(string $batchId): JsonResponse
{
    $batch = Bus::findBatch($batchId);

    return response()->json([
        'total'      => $batch->totalJobs,
        'processed'  => $batch->processedJobs(),
        'failed'     => $batch->failedJobs,
        'progress'   => $batch->progress(), // 0-100
        'finished'   => $batch->finished(),
        'cancelled'  => $batch->cancelled(),
    ]);
}

Job Chaining — Sequential Execution


php

// Jobs run in sequence — next only starts when previous succeeds
Bus::chain([
    new ValidateOrder($orderId),
    new ProcessPayment($orderId),
    new SendConfirmationEmail($orderId),
    new UpdateInventory($orderId),
    new NotifyWarehouse($orderId),
])
->catch(function (\Throwable $e) use ($orderId) {
    // Any job in the chain fails — this runs
    Order::find($orderId)?->markAsFailed();
    \Log::error('Order chain failed', ['order' => $orderId, 'error' => $e->getMessage()]);
})
->dispatch();

Rate Limiting — Throttle API Calls


php

use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;

// Define rate limiter
RateLimiter::for('external-api', function () {
    return Limit::perMinute(60); // 60 requests per minute
});

// Apply to job
class CallExternalApi implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('external-api')];
    }
}

Installing Laravel Horizon

Laravel Horizon provides a beautiful dashboard and code-driven configuration for your Laravel powered Redis queues. Horizon allows you to monitor key metrics of your queue system such as job throughput, runtime, and job failures. Medium


bash

# Install Horizon
composer require laravel/horizon

# Publish config and assets
php artisan horizon:install

# Run migrations (metrics storage)
php artisan migrate

Horizon Configuration — The Right Way

Horizon's supervisor config is the most important lever you have. Group queues by their latency contract, not by convenience. Mohamed Said


php

// config/horizon.php
return [
    'use' => 'default',

    'prefix' => env('HORIZON_PREFIX', 'horizon:'),

    // Trim completed jobs after 60 minutes
    'trim' => [
        'recent'        => 60,
        'pending'       => 60,
        'completed'     => 60,
        'recent_failed' => 10080,  // 7 days
        'failed'        => 10080,
        'monitored'     => 10080,
    ],

    // Silence noisy internal jobs from the dashboard
    'silenced' => [
        App\Jobs\Internal\HeartbeatJob::class,
    ],

    'environments' => [
        'production' => [
            // Critical jobs — payments, time-sensitive
            'supervisor-critical' => [
                'connection'   => 'redis',
                'queue'        => ['payments'],
                'balance'      => 'auto',
                'autoScalingStrategy' => 'time',
                'maxProcesses' => 10,
                'minProcesses' => 2,
                'balanceMaxShift'    => 1,
                'balanceCooldown'    => 3,
                'tries'        => 3,
                'timeout'      => 60,
                'maxTime'      => 3600,  // restart after 1 hour
                'maxJobs'      => 500,   // restart after 500 jobs
            ],

            // Important jobs — emails, notifications
            'supervisor-emails' => [
                'connection'   => 'redis',
                'queue'        => ['emails'],
                'balance'      => 'auto',
                'maxProcesses' => 5,
                'minProcesses' => 1,
                'tries'        => 3,
                'timeout'      => 30,
                'maxTime'      => 3600,
                'maxJobs'      => 500,
            ],

            // Heavy jobs — reports, PDF generation
            'supervisor-heavy' => [
                'connection'   => 'redis',
                'queue'        => ['reports'],
                'balance'      => 'simple',
                'maxProcesses' => 3,
                'minProcesses' => 1,
                'tries'        => 2,
                'timeout'      => 300,  // 5 minutes for heavy jobs
                'maxTime'      => 3600,
                'maxJobs'      => 100,
            ],

            // Background jobs — analytics, cleanup
            'supervisor-low' => [
                'connection'   => 'redis',
                'queue'        => ['low'],
                'balance'      => 'simple',
                'maxProcesses' => 2,
                'minProcesses' => 1,
                'tries'        => 1,
                'timeout'      => 120,
                'maxTime'      => 3600,
                'maxJobs'      => 500,
            ],
        ],

        'local' => [
            'supervisor-1' => [
                'connection'   => 'redis',
                'queue'        => ['default', 'payments', 'emails', 'reports', 'low'],
                'balance'      => 'simple',
                'processes'    => 3,
                'tries'        => 3,
                'timeout'      => 60,
            ],
        ],
    ],
];

The critical timeout rule:

The timeout value should always be at least a few seconds shorter than the retry_after value defined in your config/queue.php configuration file. Otherwise, your jobs may be processed twice. Laravel


php

// config/queue.php — retry_after MUST be > job timeout
'redis' => [
    'driver'      => 'redis',
    'connection'  => 'default',
    'queue'       => env('REDIS_QUEUE', 'default'),
    'retry_after' => 360,  // 6 minutes (must be > longest timeout of 300s)
    'block_for'   => null,
],

Securing Horizon in Production

By default, Horizon is only accessible locally. Secure it before production.


php

// app/Providers/HorizonServiceProvider.php
protected function gate(): void
{
    Gate::define('viewHorizon', function ($user) {
        return in_array($user->email, [
            '[email protected]',
            '[email protected]',
        ]) || $user->hasRole('admin');
    });
}

Memory Leak Prevention — Workers Die Without This

Use --max-jobs and --max-time together in production to prevent memory leaks by periodically restarting workers. Worker exits after 500 jobs or 1 hour. Supervisor or Horizon restarts the worker automatically. This is the recommended production best practice. Kamruzzaman Polash

Horizon handles this automatically via maxJobs and maxTime in your supervisor config. For queue:work deployments:


bash

# Production queue worker with memory leak prevention
php artisan queue:work redis \
    --queue=payments,emails,reports,low \
    --max-jobs=500 \
    --max-time=3600 \
    --memory=256 \
    --timeout=60

Running Horizon with Supervisor


ini

; /etc/supervisor/conf.d/laravel-horizon.conf
[program:laravel-horizon]
process_name=%(program_name)s
command=php /var/www/yourapp/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/horizon.log
stopwaitsecs=3600  ; wait up to 1 hour for jobs to finish


bash

# Apply config
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start laravel-horizon

# Check status
sudo supervisorctl status

# Restart after deployment
php artisan horizon:terminate
# Supervisor auto-restarts Horizon with new code

Monitoring — Horizon Alerts


php

// AppServiceProvider
use Laravel\Horizon\Facades\Horizon;

public function boot(): void
{
    // Alert when queue wait time exceeds 5 minutes
    Horizon::routeMailNotificationsTo('[email protected]');
    Horizon::routeSlackNotificationsTo(env('HORIZON_SLACK_WEBHOOK'));

    // Alert thresholds
    Horizon::night(); // reduce workers at night automatically
}


php

// routes/console.php — schedule metrics snapshot
Schedule::command('horizon:snapshot')->everyFiveMinutes();

Monitor these metrics in production:

  • Throughput — jobs processed per minute
  • Wait time — how long jobs sit before processing (>5min = add workers)
  • Runtime — average job execution time
  • Failed jobs — should alert immediately

Failed Job Handling


php

// config/queue.php — failed jobs table
'failed' => [
    'driver'   => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
    'database' => env('DB_CONNECTION', 'mysql'),
    'table'    => 'failed_jobs',
],


bash

# View failed jobs
php artisan queue:failed

# Retry all failed jobs
php artisan queue:retry all

# Retry specific job
php artisan queue:retry 5

# Delete failed job
php artisan queue:forget 5

# Clear all failed jobs
php artisan queue:flush

Programmatic failure handling:


php

class ProcessPayment implements ShouldQueue
{
    public int $tries = 3;
    public bool $failOnTimeout = true;

    public function handle(): void
    {
        // ...
    }

    public function failed(\Throwable $e): void
    {
        // Called after all retries exhausted
        $order = Order::find($this->orderId);
        $order?->update(['payment_status' => 'failed']);

        // Notify the customer
        $order?->user->notify(new PaymentFailed($order, $e->getMessage()));

        // Alert the team
        \Log::critical('Payment permanently failed', [
            'order_id' => $this->orderId,
            'error'    => $e->getMessage(),
        ]);
    }
}

Debounceable Jobs — Laravel 13


php

// User edits document 40 times — one job fires
#[DebounceFor(30)]
class RebuildSearchIndex implements ShouldQueue
{
    public function __construct(public int $documentId) {}

    public function debounceId(): string
    {
        return "search-index-{$this->documentId}";
    }

    public function handle(): void
    {
        Document::find($this->documentId)?->rebuildIndex();
    }
}

Horizon on AWS ElastiCache / Redis Cluster


php

// config/horizon.php — Redis Cluster support (Laravel 13)
'use' => env('HORIZON_USE', 'default'),

// config/database.php — cluster connection
'redis' => [
    'clusters' => [
        'default' => [
            ['host' => env('REDIS_HOST'), 'port' => 6379],
        ],
    ],
    'options' => [
        'cluster' => 'redis',
        'prefix'  => '', // Horizon manages its own prefix with hash tags
    ],
],

Zero-Downtime Deployment


bash

#!/bin/bash
# deploy.sh

git pull origin main
composer install --no-dev --optimize-autoloader

php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Terminate Horizon gracefully — lets current jobs finish
# Supervisor auto-restarts with new code
php artisan horizon:terminate

echo "Deployed successfully."

Never use queue:restart with Horizon — it kills workers immediately without letting current jobs finish, which can leave jobs in an inconsistent state. Always use horizon:terminate.

Testing Queue Jobs


php

// tests/Feature/Jobs/SendWelcomeEmailTest.php
use App\Jobs\SendWelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Queue;

test('welcome email is queued on registration', function () {
    Queue::fake();

    $user = User::factory()->create();
    SendWelcomeEmail::dispatch($user->id);

    Queue::assertPushed(SendWelcomeEmail::class, function ($job) use ($user) {
        return $job->userId === $user->id;
    });
});

test('welcome email job sends the email', function () {
    $user = User::factory()->create();

    Mail::fake();

    (new SendWelcomeEmail($user->id))->handle();

    Mail::assertSent(WelcomeEmail::class, fn ($mail) =>
        $mail->hasTo($user->email)
    );
});

test('failed job notifies team', function () {
    $order = Order::factory()->create();
    Notification::fake();

    $job = new ProcessPayment($order->id);
    $job->failed(new \Exception('Payment gateway timeout'));

    Notification::assertSentTo($order->user, PaymentFailed::class);
});

Complete Production Checklist

Setup:

  • Redis configured as queue driver in production
  • Horizon installed and migrated
  • Queue topology defined — separate queues by latency
  • Queue::route() in AppServiceProvider

Job design:

  • Pass IDs, not Eloquent models in constructors
  • All jobs are idempotent
  • $tries, $timeout, $backoff set on every job
  • $failOnTimeout = true on long-running jobs
  • failed() method handles permanent failures

Horizon config:

  • Supervisor per queue group
  • retry_after > timeout in queue.php
  • maxJobs and maxTime set (memory leak prevention)
  • Horizon secured behind auth gate
  • Slack alerts configured

Production:

  • Supervisor running and auto-restarting
  • horizon:snapshot scheduled every 5 minutes
  • horizon:terminate in deployment script (not queue:restart)
  • Failed jobs table monitored
  • Wait time alert configured (>5 minutes = add workers)

Wrapping Up

Laravel queues are not an advanced topic. They are a fundamental tool that every Laravel application handling real traffic needs. The moment you are sending emails, generating files, calling external APIs, or processing anything that takes more than a few hundred milliseconds — queues are the right answer.

Start simple. One queue, Redis driver, basic Horizon configuration. Then add topology as your needs grow. The architecture here scales from a side project to a platform handling millions of jobs per day — the principles are the same at every level.

The worst queue problem to have is one you discover in production because nobody set up proper monitoring. Horizon prevents that. Set it up from day one.

Tushar Modi — Full Stack Developer, Jaipur

tusharmodi.in