DEV Community

A0mineTV
A0mineTV

Posted on

Make Your Laravel App Feel Instant: The Ultimate Guide to Queues + Horizon

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
Enter fullscreen mode Exit fullscreen mode

2) Start a worker (local)

php artisan queue:work --tries=3 --timeout=30
# stop safely on deploy: php artisan queue:restart
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In production, run php artisan horizon under a process manager (systemd, Supervisor, Forge, Vapor).
Laravel will gracefully reload workers on php 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());
Enter fullscreen mode Exit fullscreen mode

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() { /* ... */ }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Dispatch it from your controller after persisting the file:

GenerateThumbnails::dispatch($storedPath)->onQueue('media')->afterCommit();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Chain jobs (strict order):

SendInvoice::withChain([
    new SyncToERP($order->id),
    new NotifyAccountant($order->id),
])->dispatch($order->id);
Enter fullscreen mode Exit fullscreen mode

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 $timeout and $tries per 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}
Enter fullscreen mode Exit fullscreen mode

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,
    ],
],
Enter fullscreen mode Exit fullscreen mode

Start Horizon (local):

php artisan horizon
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Deploy tip: run php artisan horizon:terminate during 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 $backoff set 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();
Enter fullscreen mode Exit fullscreen mode

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)