TL;DR: Stop doing slow work in HTTP requests. By setting your QUEUE_CONNECTION to redis, installing Horizon, and offloading heavy lifting to background jobs, your UI returns in milliseconds while the complex work happens behind the scenes.
Why You Need Queues Today
In a standard application flow, performing heavy tasks during a request blocks the user. Adopting a queue-based architecture provides three major advantages:
**Better UX: Your application returns a response immediately (e.g., "Request received!") while processing the actual task later.
*Performance: Heavy tasks such as generating PDFs, processing image thumbnails, or sending webhooks don't block PHP-FPM.
*Resilience*: Built-in retries and backoff strategies protect your user flow from flaky third-party APIs.
Quick Start: Redis & Horizon
To get started, update your .env file to use the Redis driver:
1) Redis driver
# .env
QUEUE_CONNECTION=redis
REDIS_CLIENT=phpredis # or predis
2) Start a worker (local)
php artisan queue:work --tries=3 --timeout=30
# stop safely on deploy: php artisan queue:restart
3) Install Horizon (dashboard + supervisor management)
composer require laravel/horizon
php artisan horizon:install
php artisan migrate
php artisan horizon
# visit /horizon for metrics, queues, failed jobs
In production, run
php artisan horizonunder a process manager (systemd, Supervisor, Forge, Vapor).
Laravel will gracefully reload workers onphp artisan horizon:terminate.
Step 1 — Queue Your Emails (The 1-Line Win)
// any Mailable can be queued:
Mail::to($user)->queue(new OrderConfirmed($order));
// or ensure dispatch after DB commit:
Mail::to($user)->queue((new OrderConfirmed($order))->afterCommit());
Or make the Mailable itself queueable:
use Illuminate\Contracts\Queue\ShouldQueue;
class OrderConfirmed extends Mailable implements ShouldQueue
{
public function __construct(public Order $order) {}
public function build() { /* ... */ }
}
Why: email providers can be slow or rate-limited—don’t block the response.
Step 2 — Advanced Job Logic (Images & PDFs)
// app/Jobs/GenerateThumbnails.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Intervention\Image\ImageManager;
use Illuminate\Support\Facades\Storage;
class GenerateThumbnails implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 60; // kill long jobs
public int $tries = 3; // retry a few times
public $backoff = [5, 30, 120]; // progressive backoff
public function __construct(public string $path) {}
public function middleware(): array
{
return [
new WithoutOverlapping($this->path), // idempotency by key
new RateLimited('media'), // throttle bursty uploads
];
}
public function handle(ImageManager $images): void
{
$img = $images->read(Storage::disk('uploads')->path($this->path));
$thumb = $img->scaleDown(width: 640)->toJpeg(80);
Storage::disk('uploads')->put("thumbs/{$this->path}", (string) $thumb);
}
}
Dispatch it from your controller after persisting the file:
GenerateThumbnails::dispatch($storedPath)->onQueue('media')->afterCommit();
Step 3 — Reliability Patterns You Actually Need
If you need the illusion of instant without a full queue, you can push work
after the response is sent (still better to use queues for retries/metrics):
SomeJob::dispatchAfterResponse($payload);
Step 4 — Batches & chains (one API call triggers many jobs)
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
$batch = Bus::batch([
new GenerateThumbnails($p1),
new GenerateThumbnails($p2),
new GenerateThumbnails($p3),
])->then(function (Batch $batch) {
// all thumbs done
})->catch(function (Batch $batch, Throwable $e) {
// at least one failed
})->finally(function (Batch $batch) {
// cleanup
})->name('product:thumbnails')->onQueue('media')->dispatch();
Chain jobs (strict order):
SendInvoice::withChain([
new SyncToERP($order->id),
new NotifyAccountant($order->id),
])->dispatch($order->id);
Step 5 — Reliability patterns you actually need
-
Idempotency: use
WithoutOverlapping($key); keep jobs small and stateless. - afterCommit(): only queue after the DB transaction is committed.
-
Timeouts & tries: set
$timeoutand$triesper job (or per worker). -
Backoff: arrays like
[5,30,120]to survive temporary outages. -
Rate limiting:
RateLimited('emails')to avoid provider throttling. -
Fail fast, inspect: Horizon shows failed jobs; fix &
queue:retry all.
Failed jobs CLI you’ll use:
php artisan queue:failed
php artisan queue:retry all
php artisan queue:forget {id}
Step 6 — Horizon: priorities, balancing, metrics
config/horizon.php example (one high‑prio queue + one for media):
'defaults' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'media', 'default'],
'balance' => 'auto', // simple | auto
'minProcesses' => 2,
'maxProcesses' => 10,
'tries' => 3,
'timeout' => 60,
'nice' => 0,
],
],
Start Horizon (local):
php artisan horizon
Production (systemd example):
# /etc/systemd/system/horizon.service
[Unit]
Description=Laravel Horizon
After=redis.service
[Service]
User=www-data
Restart=always
ExecStart=/usr/bin/php /var/www/app/artisan horizon
[Install]
WantedBy=multi-user.target
Deploy tip: run
php artisan horizon:terminateduring release; Horizon will
gracefully restart workers with the new code.
Observability
- Horizon: queue lengths, runtime, throughput, failed jobs.
- Telescope: per-request job dispatches, DB queries, logs.
- Alerts: wire a Slack webhook for failed jobs or long runtimes.
Production checklist (copy/paste)
- [ ]
QUEUE_CONNECTION=redis - [ ] Horizon installed & running under a supervisor
- [ ] Separate queues:
high,emails,media,webhooks - [ ]
$tries,$timeout, and$backoffset per job - [ ]
afterCommit()for jobs triggered by DB writes - [ ] Middleware:
WithoutOverlapping/RateLimited - [ ] Metrics + alerts for failures
- [ ] Graceful restarts on deploy (
horizon:terminate)
One-screen visual you can share
- // SLOW (blocks response)
- Mail::to($user)->send(new OrderConfirmed($order));
- generateThumbnails($path);
+ // FAST (returns instantly)
+ Mail::to($user)->queue(new OrderConfirmed($order));
+ GenerateThumbnails::dispatch($path)->onQueue('media')->afterCommit();
Wrap-up
Queues + Horizon are the easiest way to make a Laravel app feel instant:
do the essential work now, everything else later. Start with queued emails,
move image & PDF work to jobs, then batch your webhooks. Add retries/backoff,
measure with Horizon, and deploy with confidence.
Top comments (0)