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

    Laravel Signed URLs and One-Time Download Links

    Laravel Signed URLs and One-Time Download Links

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Timezone Handling: UTC, Users, and DST Bugs

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Laravel Login Throttle: Rate Limiting and Credential Defense

    Building Honest Health Check Endpoints in Laravel

    Building Honest Health Check Endpoints in Laravel

    Writing Production-Ready Laravel Artisan Commands

    Writing Production-Ready Laravel Artisan Commands

    Testing Mail in Laravel: Mailables and Assertions

    Testing Mail in Laravel: Mailables and Assertions

    Solving Low-Priority Queue Starvation in Laravel

    Solving Low-Priority Queue Starvation in Laravel

    Realistic Laravel Seeders with States and Relations

    Realistic Laravel Seeders with States and Relations

    Fixing Laravel Broadcasting Auth and 403 Errors

    Fixing Laravel Broadcasting Auth and 403 Errors

    Laravel Multi Tenancy: Single vs Multi Database

    Laravel Multi Tenancy: Single vs Multi Database