Skip to content

Laravel Queues at Scale: Lessons from 10 Million Jobs

10 min read

I've been running Laravel queues in production continuously since 2018. Here's what I've learned the hard way.

1. Job Design

Keep Jobs Small

The most common mistake I see is jobs that do too much. A job that fetches data, transforms it, and writes to three tables is hard to retry, hard to test, and impossible to parallelise.

The rule I follow: one job, one side effect.

Idempotency Is Non-Negotiable

Queues deliver at least once. Your jobs will run more than once — plan for it.

public function handle(): void { // Lock prevents double-processing $lock = Cache::lock('process-order:' . $this->orderId, 60); if (! $lock->get()) { return; // Already processing } try { $this->processOrder(); } finally { $lock->release(); } }

2. Connection Pooling

Each Horizon worker opens a database connection on boot and keeps it. With 20 workers and 5 queues, you're at 100 connections immediately. On a standard Postgres config (max_connections=100) you'll hit the ceiling fast.

Solution: PgBouncer in transaction mode in front of Postgres. Workers think they have dedicated connections but PgBouncer multiplexes 100 workers through 20 real connections.

3. Chunking Large Datasets

Never load 50,000 records into a single job:

// Bad public function handle(): void { User::all()->each(fn ($user) => $this->notify($user)); } // Good public function handle(): void { User::query()->chunkById(200, function ($users) { SendNotificationBatch::dispatch($users->pluck('id')->all()); }); }

4. Dead Letter Handling

Set $tries and $backoff, but also define failed():

public function failed(Throwable $e): void { Log::error('Job failed', [ 'job' => static::class, 'id' => $this->model->id, 'error' => $e->getMessage(), ]); $this->model->markAsFailed(); // Alert on-call if critical if ($this->isCritical()) { Notification::route('slack', config('services.slack.ops')) ->notify(new JobFailedNotification($this)); } }

5. Zero-Downtime Deployments

Deploy order that works:

  1. Deploy new code
  2. Run migrations (php artisan migrate --force)
  3. Restart Horizon (php artisan horizon:terminate) — it drains current jobs then restarts with new code

Horizon's terminate is graceful — it finishes running jobs before stopping. Never kill workers mid-job.

Key Numbers

  • Use Redis Cluster if your queue key count exceeds ~100k
  • Set retry_after to 3× your longest expected job duration
  • Monitor queue:failed count as a deployment health signal
Share X / Twitter LinkedIn
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.