The Problem Nobody Talks About
Most Laravel queue tutorials stop at php artisan queue:work. That is fine for low-traffic apps, but once you have burst traffic — a flash sale, a viral email campaign, a cron that fans out 50,000 jobs — you need three things your default setup does not give you: backpressure, dead-letter isolation, and graceful degradation.
Backpressure: Slowing the Producer
Backpressure means the producer (the HTTP request or command that dispatches jobs) slows down when the queue is already overwhelmed. Without it, a spike fills Redis memory and workers fall further behind.
Measuring Queue Depth Before Dispatching
use Illuminate\Support\Facades\Redis;
final class ThrottledDispatcher
{
private const MAX_DEPTH = 5_000;
public function dispatch(ShouldQueue $job, string $queue = 'default'): void
{
$depth = (int) Redis::llen('queues:' . $queue);
if ($depth >= self::MAX_DEPTH) {
throw new QueueSaturatedException(
"Queue '{$queue}' depth {$depth} exceeds limit."
);
}
dispatch($job)->onQueue($queue);
}
}
In an HTTP context you can catch QueueSaturatedException and return HTTP 503 with a Retry-After header. For internal fan-out commands, log the saturation and skip non-critical jobs.
Dead-Letter Queues: Isolating Poison Pills
Laravel's failed_jobs table is a dead-letter store, but it is a single flat table. When a job fails repeatedly it keeps consuming worker time on retries. A better pattern is a dedicated dead-letter queue that workers never touch automatically.
Routing Exhausted Jobs to a DLQ
use Throwable;
use Illuminate\Queue\InteractsWithQueue;
class ProcessOrderExport implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
public int $backoff = 60;
public function failed(Throwable $e): void
{
// Re-queue onto a monitored DLQ instead of only logging
dispatch(new DeadLetterJob(
originalClass: static::class,
payload: $this->order->id,
reason: $e->getMessage(),
))->onQueue('dead-letter');
}
}
DeadLetterJob itself never retries ($tries = 1) and simply persists a record to a dead_letter_events table with full context. A Filament resource or a nightly alert then surfaces these for human review.
Graceful Degradation: Shedding Load Intentionally
Not every job is equal. Sending a transactional email is critical; regenerating a report thumbnail is not. Tag jobs with a priority tier and shed low-priority work under load.
enum QueueTier: string
{
case Critical = 'critical';
case Standard = 'default';
case Background = 'background';
}
class RegenerateThumbnail implements ShouldQueue
{
public string $queue = QueueTier::Background->value;
public int $tries = 1; // shed silently on failure
}
In config/horizon.php (or your supervisor config) assign more workers to critical, fewer to background, and set --stop-when-empty on background workers during incidents:
'environments' => [
'production' => [
'supervisor-critical' => [
'queue' => 'critical',
'processes' => 10,
'balance' => 'auto',
],
'supervisor-background' => [
'queue' => 'background',
'processes' => 2,
'balance' => 'simple',
],
],
],
During a degraded incident you can temporarily scale supervisor-background processes to zero via the Horizon API or a feature flag without touching critical workers.
Observability Glue
None of this works without metrics. Emit a gauge from a scheduled command:
Schedule::call(function () {
foreach (['critical', 'default', 'background', 'dead-letter'] as $q) {
$depth = (int) Redis::llen('queues:' . $q);
Metric::gauge('queue.depth', $depth, ['queue' => $q]);
}
})->everyMinute();
Alert when dead-letter depth grows or critical depth exceeds your SLA threshold.
Key Takeaways
- Backpressure protects Redis and gives callers a clear signal to back off; return 503 from HTTP, skip from CLI.
- Dead-letter queues isolate poison-pill jobs so they do not starve healthy workers on retries.
- Tier your queues and assign worker counts per tier so you can shed background load without touching critical paths.
- Metrics on queue depth per named queue are non-negotiable in production; alert before the backlog becomes a crisis.
- Keep
DeadLetterJobsimple and non-retrying — its only job is durable persistence for human triage.