Laravel Queues in Production: What Nobody Tells You
Beyond the docs — real patterns for reliable queue jobs, retry strategies, and monitoring that I use in a production SaaS application.
Mokammel Tanvir
Software Engineer
The 2am Failure Nobody Noticed
The Laravel docs cover queues well enough to get started. They don't cover what happens six months later when a job silently fails at 2am and nobody finds out until Monday morning.
I've been running queues in production at Smart Provider LLC for over a year. Here's what I actually do now.
The Job Structure I Always Start With
Every job I write includes these properties by default. No exceptions.
class ProcessAIWorkflow implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60; // seconds between retries
public int $timeout = 120; // never leave this as unlimited
public function __construct(private readonly int $recordId) {}
public function handle(ClaudeService $claude): void
{
$record = Record::findOrFail($this->recordId);
if ($record->isAlreadyProcessed()) {
return; // idempotency check — retries happen
}
$record->update([
'processed_at' => now(),
'result' => $claude->extract($record->raw_data),
]);
}
public function failed(Throwable $e): void
{
Log::error('ProcessAIWorkflow failed', [
'record_id' => $this->recordId,
'error' => $e->getMessage(),
]);
Record::find($this->recordId)?->update(['status' => 'failed']);
}
}
The $timeout Default Will Burn You
Laravel's default job timeout is unlimited. That means a job that hangs — network issue, slow API, infinite loop — keeps the worker process blocked indefinitely. Other jobs pile up behind it.
Set $timeout on every job. For AI calls I use 120 seconds. For emails, 30. For webhooks, 15. The number depends on the job, but unlimited is never correct.
Make Jobs Idempotent
Retries happen. A worker can die mid-execution and the job gets requeued. If your job isn't idempotent — running it twice produces the same result — you'll eventually create duplicate data or process something twice.
The isAlreadyProcessed() check above is the simplest form. For critical operations, use a cache lock:
public function handle(): void
{
$lock = Cache::lock("process_record_{$this->recordId}", 120);
if (!$lock->get()) {
return; // another worker has this one
}
try {
// do the work
} finally {
$lock->release();
}
}
Horizon Config That Separates Priorities
Running everything through a single default queue works for side projects. For production SaaS, you need queue priorities — AI jobs are slow and shouldn't block email delivery.
'production' => [
'supervisor-ai' => [
'connection' => 'redis',
'queue' => ['ai-heavy'],
'processes' => 5,
'tries' => 3,
'timeout' => 180,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'emails', 'webhooks'],
'processes' => 10,
'tries' => 3,
'timeout' => 60,
],
],
Separate supervisors per queue. Slow AI jobs can't starve fast transactional ones.
Monitoring
Laravel Telescope is great for local and staging — query timeline, job inspection, all of it. In production it's too heavy. Horizon gives you queue monitoring with minimal overhead: throughput, failure rates, processing times.
Set up a Horizon notification for when queue depth exceeds a threshold. If jobs are piling up, I want to know before a user files a support ticket.
The difference between a reliable queue system and an unreliable one usually comes down to three things: $timeout, idempotency, and actually checking Horizon's failed jobs list.

Mokammel Tanvir
Full-Stack Engineer · Laravel · Vue · WordPress · AI
Building web applications with Laravel, Vue/Nuxt, and WordPress — SaaS platforms, REST APIs, and AI-integrated workflows. Open to remote and hybrid opportunities.
