Skip to content
Patterns and pitfalls I've learned running Laravel queues in production — from connection pooling and job chunking to de...

Laravel Queues at Scale: Lessons from 10 Million Jobs

Al Amin Ahamed

Al Amin Ahamed

Senior Software Engineer

· Updated 1 hour ago 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.

PHP
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:

PHP
// 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():

PHP
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 𝕏 in
Al Amin Ahamed

Al Amin Ahamed

Senior software engineer & AI practitioner. 5+ years shipping Laravel platforms, WordPress plugins, WooCommerce extensions, and AI-driven products.

About me →

More from the blog

Need this kind of work shipped?

Available for freelance and consulting.

Laravel platforms, WordPress plugins, WooCommerce extensions, and AI integrations.