Tech Verse Logo
Enable dark mode
Mastering Laravel Failed Jobs: Retries and Alerts

Mastering Laravel Failed Jobs: Retries and Alerts

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

Default queue retries in Laravel sound simple until a downstream service goes down for twenty minutes. If you set public $tries = 5; without a delay schedule, your worker burns through all five attempts in under two seconds. That's not retrying—that's DDOSing your own infrastructure or getting rate limited by Stripe.

Configuring Exponential Backoffs and Retry Limits

Laravel 12 running on PHP 8.3 gives you fine-grained control over backoff behavior directly inside the job class. Instead of relying on static retry counts, combine exponential backoff with explicit exception handling.

<?php

namespace App\Jobs;

use App\Services\PaymentGateway;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class ProcessSubscriptionPayment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * The number of times the job may be attempted.
     */
    public int $tries = 5;

    /**
     * The maximum number of unhandled exceptions to allow before failing.
     */
    public int $maxExceptions = 3;

    /**
     * Calculate the number of seconds to wait before retrying the job.
     *
     * @return array<int, int>
     */
    public function backoff(): array
    {
        return [10, 60, 300, 900];
    }

    /**
     * Determine when the job should fail due to a timeout.
     */
    public function retryUntil(): \DateTimeInterface
    {
        return now()->addHours(2);
    }

    public function __construct(
        public readonly string $subscriptionId,
        public readonly int $amountInCents
    ) {}

    public function handle(PaymentGateway $gateway): void
    {
        $gateway->charge($this->subscriptionId, $this->amountInCents);
    }
}

Notice the interaction between $tries, $maxExceptions, and retryUntil(). Setting $maxExceptions = 3 ensures that if an actual unhandled exception drops three times, the job stops retrying immediately even if $tries is set to 5. This prevents infinite retry loops on syntax errors or invalid payload schema bugs.

Another major trap: mixing up job timeouts with lock timeouts. If your worker command uses php artisan queue:work --timeout=60, but your job takes 65 seconds during high DB load, the supervisor kills the process with SIGKILL. The job remains in reserved status until retry_after expires in config/queue.php (default 90 seconds). If your retry_after value is lower than your job timeout, two separate workers will pick up and run the exact same job concurrently. Always ensure retry_after is at least 30 seconds longer than your longest running worker timeout setting.

Using the failed() Hook for Contextual Cleanup

When a job exceeds its retries or hits an unhandled fail-condition, Laravel invokes the job's failed(Throwable $exception) method. This is where state cleanup belongs, not in your controller or event listeners.

A common mistake is assuming failed() runs inside the original database transaction. It doesn't. The worker starts a fresh execution context when handling the failure hook. If your job modified partial database records before throwing, you must manually handle rollback state or mark your domain entities as failed.

<?php

namespace App\Jobs;

use App\Models\Order;
use App\Notifications\PaymentFailedNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Throwable;

class ProcessOrderFulfillment implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;

    public function __construct(public Order $order) {}

    public function handle(): void
    {
        $this->order->update(['status' => 'processing']);

        $fulfillmentService = app(FulfillmentService::class);
        $fulfillmentService->ship($this->order);

        $this->order->update(['status' => 'completed']);
    }

    public function failed(Throwable $exception): void
    {
        $this->order->update([
            'status' => 'failed',
            'failure_reason' => $exception->getMessage(),
        ]);

        Notification::route('slack', config('services.slack.webhook_url'))
            ->notify(new PaymentFailedNotification($this->order, $exception));

        Log::channel('queue_failures')->error('Order fulfillment permanently failed', [
            'order_id' => $this->order->id,
            'exception' => $exception->getMessage(),
        ]);
    }
}

Notice how we explicitly update the order state inside failed(). Without this explicit step, your database row stays stuck at processing forever, forcing your frontend or Next.js 16 dashboard to render indeterminate loading spinners to users.

Building Dead-Letter Storage and Custom Alerting

By default, Laravel inserts failed queue records into the failed_jobs database table. If you run millions of jobs a day on Redis via Laravel Horizon, writing every transient failure to MySQL adds unneeded IO disk pressure. Instead, route dead-letter jobs to a secondary queue or dedicated storage store.

You can hook into the global Queue::failing() event inside your AppServiceProvider or a dedicated QueueServiceProvider. This runs regardless of individual job classes and gives you a single place to dispatch alerts to PagerDuty, Sentry, or Slack.

Global Event Listeners

Registering a listener on JobFailed provides access to the raw payload, queue connection, and exception object. Here's how to configure global handling cleanly:

<?php

namespace App\Providers;

use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;

class QueueServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(function (JobFailed $event) {
            $jobName = $event->job->resolveName();
            $exception = $event->exception;

            Log::channel('slack')->critical("Queue Job Failed: {$jobName}", [
                'connection' => $event->connectionName,
                'queue' => $event->job->getQueue(),
                'error' => $exception->getMessage(),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
            ]);

            Http::timeout(3)->post(config('logging.alerts_webhook'), [
                'event' => 'job_failed',
                'class' => $jobName,
                'message' => $exception->getMessage(),
                'failed_at' => now()->toIso8601String(),
            ]);
        });
    }
}

Keep your failure event listeners lightweight. If your Slack webhook call hangs or times out without a short timeout setting (like Http::timeout(3) above), it blocks the worker process from picking up the next job on the queue. Always enforce low HTTP timeouts inside error handlers.

Handling Queue Failures on Frontends

Backend job failures impact user experience on the frontend. If a user triggers a background report generation from a Next.js 16 app built with React 19, the UI needs real-time visibility when background processing fails permanently.

Instead of polling an API endpoint endlessly, dispatch a Broadcast event inside the job's failed() hook to update client UI instantly via WebSockets or Server-Sent Events.

Integrating Failure Status in React 19 UI

Here is a concise pattern using React 19 hooks to reflect job status changes directly when a job fails server-side:

'use client';

import { useState, useEffect } from 'react';

interface ReportStatusProps {
  reportId: string;
  initialStatus: 'pending' | 'processing' | 'completed' | 'failed';
}

export function ReportStatusBadge({ reportId, initialStatus }: ReportStatusProps) {
  const [status, setStatus] = useState(initialStatus);

  useEffect(() => {
    if (status === 'completed' || status === 'failed') return;

    const interval = setInterval(async () => {
      try {
        const res = await fetch(`/api/reports/${reportId}/status`);
        const data = await res.json();
        setStatus(data.status);

        if (data.status === 'completed' || data.status === 'failed') {
          clearInterval(interval);
        }
      } catch (err) {
        console.error('Failed to poll report status', err);
      }
    }, 3000);

    return () => clearInterval(interval);
  }, [reportId, status]);

  if (status === 'failed') {
    return (
      <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
        Processing Failed - Click to Retry
      </span>
    );
  }

  return (
    <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
      Status: {status}
    </span>
  );
}

Key Production Takeaways

When running high-throughput queues on Laravel 12, explicit rules beat defaults every time. Set $maxExceptions to prevent runaway retries on code bugs. Use exponential backoffs so third-party APIs can recover. Always keep retry_after in your database or Redis queue config higher than worker timeouts. Finally, handle failed() hooks gracefully so your application state and user interfaces remain consistent when errors occur.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Local LLM Development with Ollama and Hardware Limits

    Local LLM Development with Ollama and Hardware Limits

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3