Dispatching jobs, chaining, batching, rate limiting, failed job handling, and Horizon — everything you need to move slow work out of the request cycle and into the background.
Every web request has a clock ticking against it. Send a welcome email, resize an uploaded image, sync data to a third-party API, generate a PDF — do any of these inside the request cycle and the user waits. Do them in the background and the user is done in milliseconds.
Laravel’s queue system is one of the most complete background job implementations in any web framework. In 2026, with Redis as the default driver and Laravel Horizon for visibility, it is also one of the most production-ready. This guide covers everything — from dispatching your first job to orchestrating hundreds of them with batches, chains, and rate limiters.
How the Queue System Works
Before writing code, a clear mental model matters. Laravel queues have three moving parts:
The job — a PHP class that implements a handle() method. It contains the work to be done.
The queue — a list (backed by Redis, database, SQS, or another driver) where jobs wait to be processed.
The worker — a long-running PHP process (php artisan queue:work) that pulls jobs off the queue and executes them one by one.
When you dispatch a job, Laravel serialises it and pushes it onto the queue. The worker picks it up, deserialises it, calls handle(), and moves on to the next job. If handle() throws an exception, the job is retried or sent to the failed jobs table.
HTTP Request
│
▼
dispatch(new SendWelcomeEmail($user))
│
▼
Redis Queue ──────────────────────────────────────────────
│ │
│ queue:work process
│ │
└──────────────────────────────────────────► job->handle()
│
✓ done | ✗ retry
Configuration and Drivers
Queue configuration lives in config/queue.php. The QUEUE_CONNECTION environment variable controls which driver is active.
# .env — recommended for production
QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
// config/queue.php — the redis connection
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90, // seconds before a reserved job is retried
'block_for' => null, // use long-polling instead of busy-wait
],
Driver Comparison in 2026
| Driver | Best for | Notes |
|---|---|---|
redis | Production (most projects) | Fast, feature-complete, Horizon support |
database | Small projects, no Redis | Slower, requires queue_jobs table |
sqs | AWS-native infrastructure | Managed, scales automatically |
sync | Local development / testing | Runs jobs immediately, inline |
null | Testing (discard all jobs) | Jobs are dropped silently |
For local development, set
QUEUE_CONNECTION=syncin.env. Jobs run inline and you can see their output immediately. Switch toredisfor staging and production.
Creating and Dispatching Jobs
Generating a Job Class
php artisan make:job SendWelcomeEmail
This creates app/Jobs/SendWelcomeEmail.php with the basic structure ready to fill in.
Anatomy of a Job
// app/Jobs/SendWelcomeEmail.php
<?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 up to 3 times
public int $timeout = 30; // kill after 30 seconds
public int $backoff = 60; // wait 60 seconds between retries
public function __construct(
public readonly User $user // SerializesModels handles the ID
) {}
public function handle(): void
{
Mail::to($this->user)->send(new WelcomeEmail($this->user));
}
public function failed(\Throwable $exception): void
{
// Called when all retries are exhausted
logger()->error('WelcomeEmail failed', [
'user_id' => $this->user->id,
'error' => $exception->getMessage(),
]);
}
}
Dispatching Jobs
// Dispatch immediately to the default queue
SendWelcomeEmail::dispatch($user);
// Dispatch with a delay (30 minutes from now)
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(30));
// Dispatch to a specific named queue
SendWelcomeEmail::dispatch($user)->onQueue('emails');
// Dispatch conditionally
SendWelcomeEmail::dispatchIf($user->wantsEmail, $user);
SendWelcomeEmail::dispatchUnless($user->unsubscribed, $user);
// Dispatch after the database transaction commits
// (prevents the job running before data is written)
SendWelcomeEmail::dispatch($user)->afterCommit();
Always use
afterCommit()when dispatching jobs inside database transactions. Without it, the worker can pick up the job before the transaction commits — and the data it needs won’t exist yet.
Model Serialisation: The Key Detail
The SerializesModels trait means you can type-hint Eloquent models in your constructor. Laravel stores only the model’s ID and rehydrates the model fresh from the database when the worker picks up the job.
// What gets stored in Redis:
// { "user": { "class": "App\\Models\\User", "id": 42, "relations": [] } }
// What the worker sees:
// A fresh User instance loaded from the database
public function __construct(
public readonly User $user,
public readonly string $subject,
public readonly array $metadata = [],
) {}
Be careful with large arrays and objects in job constructors. Anything that isn’t an Eloquent model gets serialised as raw data. Large payloads increase Redis memory usage and slow serialisation. Pass IDs and reconstruct objects inside
handle()for large data.
Queue Priority: Multiple Named Queues
Not all jobs are equal. A password reset email should jump ahead of a weekly newsletter. Laravel workers let you listen to multiple queues in priority order.
// Dispatch to specific queues
PasswordResetEmail::dispatch($user)->onQueue('critical');
SendWelcomeEmail::dispatch($user)->onQueue('emails');
GenerateMonthlyReport::dispatch()->onQueue('reports');
// Worker processes critical first, then emails, then reports
// If critical has jobs, emails and reports wait
php artisan queue:work --queue=critical,emails,reports,default
// Set a default queue on the job class itself
class GenerateMonthlyReport implements ShouldQueue
{
use Queueable;
public function __construct()
{
$this->onQueue('reports');
}
}
Job Chaining: Sequential Execution
A chain runs jobs one after another. If any job in the chain fails, subsequent jobs are not executed.
use Illuminate\Support\Facades\Bus;
Bus::chain([
new ProcessUploadedVideo($video),
new GenerateThumbnails($video),
new TranscribeAudio($video),
new NotifyUploader($video),
])->dispatch();
Chains with Callbacks
Bus::chain([
new ProcessPayment($order),
new SendOrderConfirmation($order),
new UpdateInventory($order),
])
->catch(function (\Throwable $e) use ($order) {
// Called if any job in the chain fails
$order->update(['status' => 'failed']);
RefundPayment::dispatch($order);
})
->dispatch();
Passing Data Between Chained Jobs
Jobs in a chain don’t share state directly, but you can use a shared model or cache key to pass information along.
class ProcessPayment implements ShouldQueue
{
public function handle(): void
{
$transactionId = $this->chargeCard();
// Store for the next job in the chain
Cache::put("order:{$this->order->id}:transaction", $transactionId, 300);
}
}
class SendOrderConfirmation implements ShouldQueue
{
public function handle(): void
{
$transactionId = Cache::get("order:{$this->order->id}:transaction");
// use $transactionId in the confirmation email
}
}
Job Batching: Parallel Execution with Completion Callbacks
Batches run jobs in parallel and give you callbacks for when the batch completes, fails, or finishes.
Setup
# Create the job_batches table
php artisan queue:batches-table
php artisan migrate
Creating a Batch
use Illuminate\Support\Facades\Bus;
use Illuminate\Bus\Batch;
$batch = Bus::batch([
new ProcessImage($image1),
new ProcessImage($image2),
new ProcessImage($image3),
new ProcessImage($image4),
])
->then(function (Batch $batch) {
// All jobs completed successfully
logger()->info("Batch {$batch->id} complete. Processed {$batch->totalJobs} images.");
})
->catch(function (Batch $batch, \Throwable $e) {
// First job failure — batch may still continue
logger()->error("Batch {$batch->id} encountered an error", ['error' => $e->getMessage()]);
})
->finally(function (Batch $batch) {
// Runs when the batch finishes — success or failure
ImageGallery::where('batch_id', $batch->id)->update(['processed' => true]);
})
->name('Process Gallery Images')
->allowFailures() // don't cancel the batch if one job fails
->dispatch();
// Store the batch ID to track progress
$gallery->update(['batch_id' => $batch->id]);
Adding Jobs to a Running Batch
From inside a batched job, you can add more jobs to the same batch:
class ProcessImage implements ShouldQueue
{
use Batchable;
public function handle(): void
{
if ($this->batch()->cancelled()) {
return; // check if the batch was cancelled before doing work
}
$processed = $this->resizeAndOptimise();
if ($processed->needsWatermark()) {
// Add to the current batch dynamically
$this->batch()->add([
new AddWatermark($processed),
]);
}
}
}
Tracking Batch Progress
use Illuminate\Support\Facades\Bus;
$batch = Bus::findBatch($batchId);
$batch->id; // string UUID
$batch->name; // 'Process Gallery Images'
$batch->totalJobs; // 4
$batch->processedJobs(); // 3
$batch->failedJobs; // 0
$batch->progress(); // 75 (percentage)
$batch->finished(); // false
$batch->cancelled(); // false
Rate Limiting: Preventing Overload
Laravel’s job rate limiting ensures you don’t overwhelm external APIs, databases, or services by processing too many jobs per unit of time.
Defining Rate Limiters
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
// Max 100 Stripe API calls per minute
RateLimiter::for('stripe', function (object $job) {
return Limit::perMinute(100);
});
// Max 10 SendGrid emails per second
RateLimiter::for('sendgrid', function (object $job) {
return Limit::perSecond(10);
});
// Per-user rate limit — 5 exports per hour per user
RateLimiter::for('exports', function (object $job) {
return Limit::perHour(5)->by($job->user->id);
});
}
Applying Rate Limiters to Jobs
use Illuminate\Queue\Middleware\RateLimited;
class ChargeSubscription implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('stripe')];
}
}
class SendMarketingEmail implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('sendgrid')];
}
}
When a job hits the rate limit, it’s automatically released back to the queue to be retried when the limit window resets — no manual retry logic needed.
Preventing Overlapping Execution
For jobs that must not run concurrently (like a nightly sync that queries the entire database), use the WithoutOverlapping middleware:
use Illuminate\Queue\Middleware\WithoutOverlapping;
class SyncExternalCRM implements ShouldQueue
{
public function middleware(): array
{
return [
(new WithoutOverlapping($this->account->id))
->expireAfter(300) // release lock after 5 minutes
->dontRelease(), // discard duplicate rather than requeue
];
}
}
Failed Job Handling
Failed jobs are a production reality. Laravel provides a complete system for logging, inspecting, and retrying them.
Setup
# Create the failed_jobs table
php artisan queue:failed-table
php artisan migrate
Configuring Retries and Timeouts
class ProcessWebhook implements ShouldQueue
{
public int $tries = 5; // max attempts
public int $timeout = 60; // kill after 60 seconds
// Exponential backoff: wait 10s, 60s, 180s between retries
public function backoff(): array
{
return [10, 60, 180];
}
// Or: retry until a specific time (e.g. 10 minutes from now)
public function retryUntil(): \DateTime
{
return now()->addMinutes(10);
}
}
The failed() Method
public function failed(\Throwable $exception): void
{
// This runs after all retries are exhausted
// Use it for cleanup, notifications, and state updates
$this->order->update(['status' => 'payment_failed']);
Notification::send(
$this->order->user,
new PaymentFailedNotification($this->order)
);
// Alert the team
if (app()->isProduction()) {
SlackAlert::dispatch('#payments', "Payment job failed: {$exception->getMessage()}");
}
}
Inspecting and Retrying Failed Jobs
# List all failed jobs
php artisan queue:failed
# Retry a specific failed job by ID
php artisan queue:retry 5
# Retry all failed jobs
php artisan queue:retry all
# Retry failed jobs for a specific queue
php artisan queue:retry --queue=emails
# Delete a specific failed job
php artisan queue:forget 5
# Delete all failed jobs
php artisan queue:flush
Programmatic Retry
use Illuminate\Support\Facades\Queue;
// Retry programmatically (useful for admin dashboards)
$failedJob = DB::table('failed_jobs')->find($id);
Queue::connection($failedJob->connection)
->pushRaw($failedJob->payload, $failedJob->queue);
Job Middleware
Job middleware lets you wrap job execution with reusable logic — just like HTTP middleware wraps requests.
// app/Jobs/Middleware/EnsureFeatureIsEnabled.php
namespace App\Jobs\Middleware;
class EnsureFeatureIsEnabled
{
public function __construct(private string $feature) {}
public function handle(object $job, callable $next): void
{
if (!Feature::active($this->feature)) {
// Feature is off — discard the job silently
$job->delete();
return;
}
$next($job);
}
}
// Apply to a job:
class SendAiSummary implements ShouldQueue
{
public function middleware(): array
{
return [
new EnsureFeatureIsEnabled('ai_summaries'),
new RateLimited('openai'),
];
}
}
Long-Running Jobs with Timeouts and Memory
For jobs that process large datasets, manage memory and time explicitly.
class ImportLargeCSV implements ShouldQueue
{
public int $timeout = 600; // 10 minutes max
public int $tries = 1; // don't retry — pick up where you left off instead
public function handle(): void
{
$lastProcessed = Cache::get("import:{$this->import->id}:cursor", 0);
$this->import->rows()
->where('id', '>', $lastProcessed)
->chunk(500, function ($rows) {
foreach ($rows as $row) {
$this->processRow($row);
// Save cursor — if the job times out, restart from here
Cache::put("import:{$this->import->id}:cursor", $row->id, 3600);
}
// Release memory between chunks
gc_collect_cycles();
});
}
}
Laravel Horizon: Visibility for Production Queues
Horizon is Laravel’s queue dashboard for Redis-backed queues. It gives you real-time visibility into throughput, wait times, job volume, and failed jobs — and lets you configure worker pools declaratively in code.
Installation
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
Configuring Workers in config/horizon.php
// config/horizon.php
'environments' => [
'production' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['critical', 'default'],
'balance' => 'auto', // auto-scales workers based on load
'minProcesses' => 1,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 60,
],
'supervisor-emails' => [
'connection' => 'redis',
'queue' => ['emails'],
'balance' => 'simple',
'processes' => 3,
'tries' => 5,
],
'supervisor-reports' => [
'connection' => 'redis',
'queue' => ['reports'],
'balance' => 'simple',
'processes' => 2,
'tries' => 1,
'timeout' => 600, // long timeout for report generation
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['default'],
'balance' => 'false',
'processes' => 3,
'tries' => 3,
],
],
],
Running Horizon
# Start Horizon (replaces queue:work in production)
php artisan horizon
# Check Horizon status
php artisan horizon:status
# Pause all workers gracefully
php artisan horizon:pause
# Resume workers
php artisan horizon:continue
# Terminate Horizon gracefully (workers finish current jobs)
php artisan horizon:terminate
Deploying Horizon with Supervisor
In production, Supervisor keeps Horizon alive and restarts it if it crashes:
; /etc/supervisor/conf.d/horizon.conf
[program:horizon]
process_name=%(program_name)s command=php /var/www/your-app/artisan horizon autostart=true autorestart=true user=www-data redirect_stderr=true stdout_logfile=/var/www/your-app/storage/logs/horizon.log stopwaitsecs=3600 ; wait up to 1 hour for jobs to finish on deploy
# After deploying new code, signal Horizon to reload workers
php artisan horizon:terminate
# Supervisor will restart it automatically with new code
Horizon Metrics and Monitoring
Horizon exposes throughput and wait-time metrics. Store snapshots for the dashboard:
// app/Console/Kernel.php — run every 5 minutes
protected function schedule(Schedule $schedule): void
{
$schedule->command('horizon:snapshot')->everyFiveMinutes();
}
Protecting the Dashboard
// app/Providers/HorizonServiceProvider.php
use Laravel\Horizon\Horizon;
public function boot(): void
{
Horizon::auth(function ($request) {
return $request->user()?->isAdmin() ?? false;
});
}
Testing Jobs
// In tests — use the Queue fake to assert without actually running jobs
use Illuminate\Support\Facades\Queue;
public function test_welcome_email_is_dispatched_on_registration(): void
{
Queue::fake();
$this->post('/register', [
'name' => 'Taylor',
'email' => 'taylor@example.com',
'password' => 'password',
]);
Queue::assertPushed(SendWelcomeEmail::class, function ($job) {
return $job->user->email === 'taylor@example.com';
});
Queue::assertPushedOn('emails', SendWelcomeEmail::class);
Queue::assertNotPushed(SomethingElse::class);
}
// Test the job itself in isolation
public function test_welcome_email_job_sends_mail(): void
{
Mail::fake();
$user = User::factory()->create();
(new SendWelcomeEmail($user))->handle();
Mail::assertSent(WelcomeEmail::class, fn ($mail) =>
$mail->hasTo($user->email)
);
}
Production Checklist
Before going live with a queue-backed feature, verify each of these:
✓ QUEUE_CONNECTION=redis (not sync or database) in production .env
✓ Failed jobs table migrated (queue:failed-table)
✓ Job batches table migrated (queue:batches-table) if using batches
✓ retry_after in config/queue.php is longer than your longest job timeout
✓ All jobs have $tries and $timeout set explicitly
✓ All jobs have a failed() method for post-failure cleanup
✓ afterCommit() used on jobs dispatched inside transactions
✓ Named queues for priority (critical, default, reports)
✓ Horizon installed and configured for each environment
✓ Supervisor configured to keep Horizon alive
✓ horizon:snapshot scheduled every 5 minutes
✓ Horizon dashboard behind auth gate
✓ queue:work --stop-when-empty used in CI (not a running worker)
✓ deploy process runs horizon:terminate to reload workers
A Realistic Job for Reference
Here is a complete, production-ready job that combines the patterns above:
<?php
namespace App\Jobs;
use App\Models\Order;
use App\Models\User;
use App\Notifications\OrderInvoiceReady;
use App\Services\PdfService;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
class GenerateOrderInvoice implements ShouldQueue
{
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public array $backoff = [30, 60, 120];
public function __construct(
public readonly Order $order
) {
$this->onQueue('documents');
}
public function middleware(): array
{
return [
new WithoutOverlapping("invoice:{$this->order->id}"),
new RateLimited('pdf-service'),
];
}
public function handle(PdfService $pdf): void
{
if ($this->batch()?->cancelled()) {
return;
}
$path = "invoices/order-{$this->order->id}.pdf";
$content = $pdf->generate('invoice', [
'order' => $this->order->load('items', 'user'),
]);
Storage::put($path, $content);
$this->order->update(['invoice_path' => $path]);
$this->order->user->notify(new OrderInvoiceReady($this->order, $path));
}
public function failed(\Throwable $exception): void
{
logger()->error('Invoice generation failed', [
'order_id' => $this->order->id,
'error' => $exception->getMessage(),
]);
$this->order->user->notify(
new InvoiceGenerationFailedNotification($this->order)
);
}
}
Final Thoughts
Laravel’s queue system — jobs, chains, batches, rate limiters, middleware, and Horizon — covers every background job pattern you’ll encounter in production. The pieces compose well: a job can be part of a chain, which is part of a batch, which is rate-limited, which is monitored by Horizon.
The investment is front-loaded. Setting up Redis, Horizon, Supervisor, and the failed jobs table takes an afternoon. What you get in return is a request cycle that never blocks on slow work, a dashboard that shows you exactly what your workers are doing, and a retry system that handles transient failures without waking you up at 2am.
Move the slow work to the background. Your users will notice.
