Laravel queues and background jobs are easy to start with and easy to get quietly wrong. One of my recent Laravel SaaS builds does almost everything in the background web crawling, data enrichment, scheduled email sending and the queue layer went through a real hardening pass after production found the gaps. Here’s the design that came out of it, including the mistakes.
Five queues, five workers, and an empty default
The system runs on the database queue driver Redis was available but not required, and for this workload the database was enough. The important decision wasn’t the driver. It was splitting work across named queues, each with its own long-running worker:
fast— user-facing jobs under 60 seconds, no external APIsslow— external APIs and web scraping, anything that can take minutessending— scheduled outbound email workreplies— a priority lane processed beforesending, so handling an inbound reply never waits behind bulk sends- a fifth queue for one isolated subsystem’s operations
The default queue must stay empty in production. That’s a rule, not an observation: every job declares public $queue explicitly. If work lands on default, something forgot to say where it belongs.
Why the split matters: a batch of slow crawl jobs will happily occupy every worker you have. If user-facing work shares those workers, your UI’s “processing…” state stops meaning anything. Isolation makes latency predictable per class of work.
Every job declares its own contract
Each job class states four things explicitly:
php
public string $queue = 'slow';
public int $timeout = 600;
public int $tries = 2;
public array $backoff = [30, 120];
Plus a golden rule that cost me real debugging time: the worker’s --timeout must exceed every job $timeout on its queue. If it doesn’t, the worker kills the process mid-job and you get a confusing MaxAttemptsExceededException on the next pickup instead of a clean failure.
Two more standards saved me repeatedly. Jobs receive database IDs, not model instances, and re-check the record’s status at the top of handle() — so a retried or duplicated job becomes a no-op instead of a double charge. And external-API work is dispatched in bounded batches (50 records for metrics, 25 for scraping) with a 15-second stagger between batches, which keeps third-party APIs comfortable and the queue table breathable.
The hardening pass: what actually broke
The honest part. Several early jobs didn’t declare $tries at all, and the slow worker ran with --tries=1. Result: the first transient failure a timeout from a metrics API, a flaky remote host didn’t retry. It threw MaxAttemptsExceededException and the job was gone.
The fix was a systematic pass, not a patch: explicit tries and backoff on every job, batch sizes cut down (one job went from 500-record batches to 50), stagger increased from a few seconds to 15, notifications moved onto the fast queue so they stop competing with heavy work, and the slow worker’s retry setting aligned with the jobs it runs. Scheduled jobs got double protection against pile-ups: withoutOverlapping() at the scheduler level plus ShouldBeUnique on the job with a lock window just under the schedule interval and stale-lock pruning for the case where a crashed worker abandons its lock.
If any of this sounds like over-engineering, it’s the opposite. Each rule exists because its absence produced a specific production incident or a near miss.
Operating it
Workers run under systemd with Restart=always, plus --max-jobs and --max-time so each worker exits cleanly every hour and gets respawned — leaked memory is released, and workers never run stale code after a deploy (php artisan queue:restart after every release is non-negotiable). Monitoring can start as simply as watching SELECT queue, COUNT(*) FROM jobs GROUP BY queue — if slow grows without bound, dispatch is outpacing throughput and it’s time for another worker or smaller batches.
Takeaway
Queues fail in ways your happy path never shows: missing retry declarations, timeout mismatches, pile-ups, starvation. Declare every job’s contract explicitly, isolate classes of work, and treat batch sizes and staggering as production settings rather than afterthoughts. The rest of this build’s architecture is in the Laravel + FilamentPHP SaaS post.