Back to blog

Debug Silent Laravel Queue Failures With AI

👍1❤️1

Laravel queue jobs can fail silently while Horizon still shows success. Learn why it happens and how AI helps trace the swallowed exception fast.

4 views 6 min read
Share

Your Laravel queue dashboard shows zero failed jobs, Horizon looks calm, and yet a customer swears their order confirmation email never arrived. This is the classic symptom of a Laravel queue job failing silently — the job runs, hits an error, and still reports success because something inside handle() is swallowing the exception before Laravel's queue worker ever sees it.

Why Laravel Queue Jobs Fail Silently

Laravel's queue worker is actually very good at surfacing failures. When a job throws an uncaught Throwable, the worker catches it, calls the job's failed() method, writes a row to the failed_jobs table, and (if you have Horizon or a logging channel wired up) reports it. The problem almost never lives in Laravel itself — it lives in application code that intercepts the exception first.

The most common pattern looks like this: a developer wraps the body of handle() in a try/catch to "handle errors gracefully," logs the exception, and returns. From the queue's point of view, the job finished without throwing anything, so it's marked successful. The failed_jobs table stays empty, Horizon's metrics stay green, and the only trace of the real failure is a single log line buried among thousands of others — if anyone even logged it.

Other causes worth ruling out early: the worker process itself being OOM-killed mid-job (common with nodejs-adjacent memory-heavy jobs like image processing or PDF generation), a job stuck in reserved state because retry_after is shorter than the actual job runtime, or a queue connection silently pointed at the wrong driver in a staging environment.

Reproducing the Failure Before You Debug It

Before reaching for any tool, isolate the job. Run it synchronously instead of through the worker so exceptions surface directly in your terminal:

bash
php artisan queue:work --once --queue=emails -vvv

If the job "succeeds" here too despite you knowing the underlying action failed (no email actually sent, no record actually updated), you've confirmed the bug is inside the job's own error handling, not in the queue infrastructure. That distinction matters — it tells you exactly where to point the debugger, and it's exactly the context an AI assistant needs to be useful instead of guessing.

Using AI to Diagnose a Silent Queue Failure

This is where an AI coding assistant genuinely earns its place in the workflow — not by writing new code from scratch, but by tracing control flow through a file faster than a tired human scanning it for the fifth time. The key is giving it the right context: the job class, the relevant log output, and a precise description of the symptom.

A prompt that actually works:

Here is a Laravel queued job class and the log output from a run where
the underlying action failed but the job was still marked successful:

[paste ProcessOrderConfirmation.php]

[paste the relevant lines from storage/logs/laravel.log]

Trace every catch block in handle(). For each one, tell me:
1. What exception types it catches
2. Whether it rethrows, calls $this->fail(), or silently returns
3. Whether a Throwable thrown deeper in the call stack (e.g. from a
   third-party HTTP client) could bypass this catch entirely

Then tell me exactly which line is responsible for the job reporting
success despite the real failure.

Fed the actual job class below, an assistant reliably flags line 19 — the bare catch (\Exception $e) { Log::error($e); return; } — as the culprit, and explains that returning from handle() after catching is functionally identical to succeeding, from the queue's perspective.

The Fix

php
class ProcessOrderConfirmation implements ShouldQueue
{
    public $tries = 3;

    public function handle(Mailer $mailer): void
    {
        try {
            $mailer->send($this->order);
        } catch (\Throwable $e) {
            // Log for visibility, but let the queue know this actually failed
            Log::error('Order confirmation failed', [
                'order_id' => $this->order->id,
                'exception' => $e->getMessage(),
            ]);

            $this->fail($e);
        }
    }

    public function failed(\Throwable $e): void
    {
        // Runs only when the job is genuinely marked failed —
        // notify on-call, requeue a fallback, etc.
        Log::critical('Order confirmation permanently failed', [
            'order_id' => $this->order->id,
        ]);
    }
}

Two changes matter here. First, catching \Throwable instead of \Exception means a TypeError or other fatal error from a third-party SDK won't slip past the catch block unnoticed. Second, and more important, calling $this->fail($e) explicitly tells Laravel's queue system this job did not succeed — it populates failed_jobs, triggers failed(), and makes the failure visible in Horizon exactly the way an uncaught exception would.

Common Mistakes When Debugging Silent Queue Failures

  • Assuming an empty failed_jobs table means nothing is failing — it only means nothing has been reported as failing
  • Catching \Exception instead of \Throwable, which misses fatal errors and type errors thrown by dependencies
  • Logging an error but forgetting to call $this->fail() or rethrow, leaving the queue worker with nothing to catch
  • Debugging against the queued worker first instead of running the job with --once to rule out infrastructure issues

Best Practices to Prevent Silent Failures

  • Never catch an exception in a job without either rethrowing it or calling $this->fail($e)
  • Set a sensible failed() method on every job that touches an external service — email, payments, webhooks
  • Alert on failed_jobs row counts, not just on error logs, since logs get buried but a failed-jobs count is a clean signal
  • Use --once locally to separate "the job's logic is broken" from "the queue is misconfigured"

Frequently Asked Questions

Why does Horizon show my job as completed if it actually failed? Because Horizon (and the queue system generally) only knows a job failed when an exception escapes handle() uncaught, or when you explicitly call $this->fail(). If your own code catches the exception and returns normally, the job completes from the queue's perspective, regardless of what actually happened inside it.

Should I catch \Exception or \Throwable in a queued job? Catch \Throwable if you're catching at all, since it also covers Error and its subclasses like TypeError and DivisionByZeroError, which don't extend \Exception. Just make sure every catch block either rethrows or explicitly fails the job — don't let it return silently.

Can AI actually find bugs like this reliably? It's reliable at exactly this kind of task: tracing control flow through a bounded amount of code when given the actual file and real log output. It's far less reliable if you ask it to guess at a fix without pasting the real job class and logs — the specificity of the input is what makes the diagnosis accurate.

Key Takeaways

A silent Laravel queue failure is almost always a swallowed exception, not a Laravel bug. Reproduce the job with queue:work --once first to confirm the issue is in your own error handling, then either paste the job and logs into an AI assistant to trace every catch block or do it manually — the fix is the same either way: catch \Throwable, and always call $this->fail($e) or rethrow instead of returning quietly. Do that consistently and your failed_jobs table becomes a trustworthy signal again, which is the entire point of having one.

Found this useful? Share it.

Share