Laravel Queues at Scale: Backpressure, Dead-Letter Patterns, and Job Observability
#laravel #queues #observability #production

Laravel Queues at Scale: Backpressure, Dead-Letter Patterns, and Job Observability

3 min read Mohamed Said Mohamed Said

The Problem Nobody Talks About

Most Laravel queue tutorials stop at dispatch() and php artisan queue:work. In production, the real challenges are: what happens when consumers fall behind producers, how do you safely handle poison-pill jobs, and how do you get meaningful signals out of your workers without drowning in noise?

This article addresses all three.


Backpressure: Slowing the Producer

Backpressure means the consumer signals the producer to slow down when it cannot keep up. Laravel has no built-in backpressure primitive, but you can build one cheaply with Redis and a rate-aware dispatcher.

// app/Queue/BackpressureDispatcher.php
final class BackpressureDispatcher
{
    private const THRESHOLD = 5_000;

    public function __construct(
        private readonly Redis $redis,
        private readonly string $queue = 'default',
    ) {}

    public function dispatch(ShouldQueue $job): void
    {
        $depth = (int) $this->redis->llen('queues:' . $this->queue);

        if ($depth >= self::THRESHOLD) {
            throw new QueueSaturatedException(
                "Queue [{$this->queue}] depth {$depth} exceeds threshold."
            );
        }

        dispatch($job)->onQueue($this->queue);
    }
}

The caller catches QueueSaturatedException and either retries with exponential back-off or returns a 202 Accepted with a Retry-After header. This keeps your queue depth bounded without dropping work.


Dead-Letter Queues Without a Broker

Laravel's failed_jobs table is a dead-letter store, but it lacks routing. A better pattern is a dedicated dead-letter queue that jobs are explicitly moved to after exhausting retries, preserving the original payload and failure context.

// app/Jobs/Concerns/RoutesToDeadLetter.php
trait RoutesToDeadLetter
{
    public function failed(Throwable $e): void
    {
        DeadLetterJob::dispatch(
            originalJob: static::class,
            payload: serialize($this),
            reason: $e->getMessage(),
            failedAt: now(),
        )->onQueue('dead-letter');
    }
}
// app/Jobs/DeadLetterJob.php
final class DeadLetterJob implements ShouldQueue
{
    public int $tries = 1;

    public function __construct(
        public readonly string $originalJob,
        public readonly string $payload,
        public readonly string $reason,
        public readonly Carbon $failedAt,
    ) {}

    public function handle(): void
    {
        // Persist to dead_letter_events table for audit + replay UI
        DeadLetterEvent::create([
            'job' => $this->originalJob,
            'payload' => $this->payload,
            'reason' => $this->reason,
            'failed_at' => $this->failedAt,
        ]);
    }
}

A Filament resource over dead_letter_events gives ops teams a replay button without touching artisan queue:retry.


Structured Job Observability

Log lines are noise. Structured spans are signal. Use Laravel's Queue::before and Queue::after hooks to emit consistent telemetry.

// app/Providers/QueueServiceProvider.php
public function boot(): void
{
    Queue::before(function (JobProcessing $event) {
        $this->startTimer($event->job->getJobId());
    });

    Queue::after(function (JobProcessed $event) {
        $elapsed = $this->stopTimer($event->job->getJobId());

        Log::channel('structured')->info('job.processed', [
            'job' => $event->job->resolveName(),
            'queue' => $event->job->getQueue(),
            'duration_ms' => $elapsed,
            'attempts' => $event->job->attempts(),
            'connection' => $event->connectionName,
        ]);
    });

    Queue::failing(function (JobFailed $event) {
        Log::channel('structured')->error('job.failed', [
            'job' => $event->job->resolveName(),
            'error' => $event->exception->getMessage(),
            'attempts' => $event->job->attempts(),
        ]);
    });
}

Pipe the structured channel to a JSON log driver and ingest into your observability stack (Grafana Loki, Datadog, etc.). You now have p95 job latency per class without a paid APM agent.


Key Takeaways

  • Backpressure is a producer responsibility — check queue depth before dispatching and surface Retry-After to callers.
  • Dead-letter queues should be first-class entities with a replay UI, not a graveyard in failed_jobs.
  • Structured telemetry from Queue::before/after hooks gives you latency histograms and failure rates per job class for free.
  • Keep $tries low (2–3) and $backoff exponential; long retry chains mask systemic failures.
  • A dedicated dead-letter queue worker with a single process prevents replay storms from starving production queues.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Should I use a real message broker like RabbitMQ instead of building these patterns in Laravel?
For most SaaS workloads, Redis-backed Laravel queues with explicit backpressure and dead-letter routing are sufficient and far simpler to operate. Reach for a broker when you need multi-consumer fan-out, cross-service routing, or guaranteed ordering across partitions — not just because your queue is busy.
Q02 How do I replay jobs from the dead-letter table without re-queuing poison pills?
Deserialize the stored payload, inspect it, optionally patch it, then dispatch a fresh instance. Add a `replayed_from` metadata field so you can detect replay loops and halt after a configurable number of replay attempts per original job ID.
Q03 Does checking queue depth on every dispatch add meaningful latency?
An Redis LLEN call is a single O(1) command, typically sub-millisecond on a local or same-datacenter Redis. Cache the result for 500ms with an in-memory static property if you dispatch in tight loops.

Continue reading

More Articles

View all