← Back to blog

Taming Laravel Queue Jobs: Lessons From a SaaS That Almost Fell Over

18 Feb 2026 · 6 min read

Taming Laravel Queue Jobs: Lessons From a SaaS That Almost Fell Over

A few months back I was brought in to help a SaaS client whose app had developed a peculiar habit: it worked beautifully during demos and fell apart on Monday mornings. Classic. After a couple of hours digging through logs and scratching my head over some suspiciously large Redis memory usage, the culprit was clear — their queues were an absolute mess.

Not the kind of mess where someone had done something obviously wrong. The kind of mess that creeps up on you when a project grows faster than its infrastructure planning. I've seen it more than once in my years of building with Laravel, and I suspect a lot of you have too.

So let me share what we found, how we fixed it, and the habits I now build into every SaaS project from day one.

The Problem: One Queue to Rule Them All

The first thing I noticed was that everything — emails, invoice generation, webhook deliveries, report exports, data sync jobs — was being dispatched to a single default queue. This is fine when your app is small. The moment it isn't, you've got a problem.

A slow report export job was blocking a password reset email from sending. Users were complaining they weren't getting their login links. The emails weren't failing — they were just sitting behind a 45-second PDF generation job, patiently waiting their turn.

The fix here is straightforward: use multiple named queues with appropriate priority.

// High priority - user-facing, time-sensitive
Mail::to($user)->queue(new WelcomeEmail($user))->onQueue('high');

// Default priority - standard background work
GenerateInvoice::dispatch($invoice)->onQueue('default');

// Low priority - can wait, nobody's watching
ExportMonthlyReport::dispatch($report)->onQueue('low');

Then in your horizon.php config (and yes, you should be using Horizon), you set the queue priority order:

'queue' => ['high', 'default', 'low'],

Horizon processes high first, moves to default only when high is empty, and so on. Simple, effective, and something I now set up on every single project before the first job is ever dispatched.

Horizon Is Non-Negotiable for SaaS

If you're running a SaaS and you're not using Laravel Horizon, please stop reading this and go install it right now. I'll wait.

Seriously though — Horizon gives you a real-time dashboard of your queues, job throughput, failed jobs, and wait times. It's one of those tools that costs you nothing (it's free and open source) and gives you back enormous amounts of debugging time.

For this client, once Horizon was in place we could immediately see that the default queue had a throughput of about 200 jobs per minute but was receiving 400 during peak hours. Classic backpressure problem. We spun up an additional queue worker and the wait times dropped from minutes to seconds.

Failed Jobs Need a Strategy, Not Just a Table

Every Laravel app has a failed_jobs table. Most apps have no plan for what to do with it.

Failed jobs happen. Network blips, third-party APIs going down, unexpected data edge cases — these are facts of life. What matters is how you respond. Here's the approach I use now:

1. Set sensible retry limits and backoff

public int $tries = 3;
public int $backoff = 60; // seconds between retries

Don't hammer a failing external API five times in five seconds. Give it breathing room.

2. Use the failed() method to alert yourself

public function failed(Throwable $exception): void
{
    // Notify the team via Slack, email, or whatever you use
    Notification::route('slack', config('services.slack.webhook'))
        ->notify(new JobFailedNotification($this, $exception));
}

I use this on any job that's genuinely critical — payment processing, subscription management, anything where silent failure would be costly.

3. Review and prune regularly

Set up a scheduled command to prune old failed jobs so your table doesn't become a graveyard:

$schedule->command('queue:prune-failed --hours=72')->daily();

Job Middleware: The Underused Gem

One thing I've started leaning on heavily is job middleware. It's been in Laravel for a while but I still see plenty of codebases that don't use it.

For this client, we had jobs hammering a third-party API that had strict rate limits. The solution was a rate-limiting middleware applied at the job level:

public function middleware(): array
{
    return [new RateLimited('third-party-api')];
}

Combined with Laravel's built-in ThrottlesExceptions middleware for flaky external services, this gave us resilience without any changes to the actual job logic. Clean and composable.

Batching for the Big Stuff

The monthly report export was the job that kept causing grief. It was a single monolithic job doing too much. We broke it into smaller jobs and used Bus::batch() to coordinate them:

Bus::batch([
    new FetchUserData($report),
    new FetchTransactionData($report),
    new FetchEngagementData($report),
])->then(function (Batch $batch) use ($report) {
    CompileAndDeliverReport::dispatch($report);
})->dispatch();

Now if one data fetch fails, we know exactly which one. Retrying is surgical rather than starting the whole thing from scratch. The client's users started getting their reports on time, and the support inbox quietened down noticeably.

What I Set Up on Every New SaaS Project

Here's my current queue setup checklist for any new Laravel SaaS:

  • ✅ Laravel Horizon installed and configured
  • ✅ Named queues: high, default, low (at minimum)
  • failed() method on critical jobs with Slack alerting
  • ✅ Sensible retry counts and exponential backoff
  • ✅ Rate limiting middleware for any jobs touching external APIs
  • ✅ Scheduled pruning of failed jobs
  • ✅ Horizon metrics reviewed as part of any performance conversation

Queues are one of the things that make Laravel genuinely delightful to build with. The API is clean, Horizon makes observability easy, and the flexibility is remarkable. But like any powerful tool, a bit of thought upfront saves you a lot of pain down the road.

If your SaaS is growing and you haven't revisited your queue setup since you first wired it up, it might be worth an afternoon of attention. Future you — and your users — will be grateful.

Have questions about any of this, or a queue horror story of your own? I'd love to hear it — drop me a message or find me on social. Always happy to talk Laravel.