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:
- Deploy new code
- Run migrations (
php artisan migrate --force) - 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_afterto 3× your longest expected job duration - Monitor
queue:failedcount as a deployment health signal
Al Amin Ahamed
Senior software engineer & AI practitioner. Building things in Laravel, PHP, and TypeScript.
About me →← Older
Adding PHPStan to a WordPress Plugin (Without Losing Your Mind)
Newer →
Building a RAG Pipeline in Laravel with pgvector
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.