Laravel 4 min read

Laravel Queues and Background Jobs: What a Production SaaS Taught Me

Avatar for Ahmad Tahir By Ahmad Tahir
Laravel queues and background jobs architecture with five named queues and dedicated workers

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 APIs
  • slow — external APIs and web scraping, anything that can take minutes
  • sending — scheduled outbound email work
  • replies — a priority lane processed before sending, 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.

Leave a Reply

Your email address will not be published.

Keep reading

How to implement Database Notifications in Laravel FilamentPHP V3?

In this tutorial, I’ll walk you through setting up database notifications using Filament in your Laravel application. We’ll cover how to send notifications and configure real-time updates. Step 1: Install Notifications Require the Notifications package using Composer: Run the following command to install the Notifications package assets: Step 2: Implement Notification Use the Filament\Notifications\Notification class to create […]

2 min read Read Guide