Laravel's ThrottlesExceptions Gets Smarter: Introducing failWhen()

Laravel's ThrottlesExceptions Gets Smarter: Introducing failWhen()

Laravel's ThrottlesExceptions middleware just gained a powerful new companion with the failWhen() method. Now you can mark jobs as failed instead of just deleting them, giving you better control over job chain behavior.

The Gap Between Deleting and Failing Jobs

Previously, the ThrottlesExceptions middleware offered the deleteWhen() method to completely remove jobs when specific exceptions occurred. While useful, this approach had limitations, especially in job chains where you needed to halt execution rather than simply discard problematic jobs:

// The old approach - job gets deleted, chain continues
public function middleware(): array
{
    return [
        (new ThrottlesExceptions(2, 10 * 60))
            ->deleteWhen(CustomerDeletedException::class)
    ];
}

The new failWhen() method provides a middle ground, marking jobs as failed while preserving the failure information for debugging and stopping job chain execution:

// The new approach - job fails, chain stops
public function middleware(): array
{
    return [
        (new ThrottlesExceptions(2, 10 * 60))
            ->deleteWhen(CustomerDeletedException::class)
            ->failWhen(fn (\Throwable $e) => $e instanceof CriticalSystemException)
    ];
}

Real-World Example

Consider a complex e-commerce order processing system that uses job chains to handle order fulfillment, inventory updates, payment processing, and customer notifications. When certain critical exceptions occur, you want to stop the entire chain rather than risk partial processing:

<?php

namespace App\Jobs;

use App\Exceptions\PaymentGatewayDownException;
use App\Exceptions\InventorySystemException;
use App\Exceptions\CustomerAccountSuspendedException;
use App\Exceptions\TemporaryApiException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\ThrottlesExceptions;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

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

    public $tries = 5;

    public function __construct(
        public Order $order
    ) {}

    public function handle(PaymentService $paymentService): void
    {
        Log::info('Processing payment for order', ['order_id' => $this->order->id]);

        try {
            $result = $paymentService->processPayment($this->order);
            
            $this->order->update([
                'payment_status' => 'completed',
                'transaction_id' => $result->transactionId
            ]);

        } catch (PaymentGatewayDownException $e) {
            // This should be retried, not failed immediately
            Log::warning('Payment gateway temporarily down', [
                'order_id' => $this->order->id,
                'error' => $e->getMessage()
            ]);
            throw $e;

        } catch (CustomerAccountSuspendedException $e) {
            // This should fail the entire chain - no point continuing
            Log::error('Customer account suspended during payment', [
                'order_id' => $this->order->id,
                'customer_id' => $this->order->customer_id
            ]);
            throw $e;

        } catch (InventorySystemException $e) {
            // Critical system error - fail the chain
            Log::critical('Inventory system error during payment processing', [
                'order_id' => $this->order->id,
                'error' => $e->getMessage()
            ]);
            throw $e;
        }
    }

    public function middleware(): array
    {
        return [
            (new ThrottlesExceptions(3, 5 * 60))
                // Delete jobs for permanently deleted customers - no retry needed
                ->deleteWhen(CustomerDeletedException::class)
                // Fail jobs for critical system issues - stops the chain
                ->failWhen(function (\Throwable $e) {
                    return $e instanceof CustomerAccountSuspendedException ||
                           $e instanceof InventorySystemException ||
                           ($e instanceof PaymentGatewayDownException && $e->isPermanentFailure());
                })
                // Only throttle temporary API exceptions
                ->when(fn (\Throwable $e) => $e instanceof TemporaryApiException)
                // Report critical failures for immediate attention
                ->report(function (\Throwable $e) {
                    return $e instanceof InventorySystemException ||
                           $e instanceof CustomerAccountSuspendedException;
                })
        ];
    }

    public function failed(\Throwable $exception): void
    {
        Log::error('Order payment processing failed permanently', [
            'order_id' => $this->order->id,
            'exception' => $exception->getMessage(),
            'chain_stopped' => true
        ]);

        // Update order status and notify relevant parties
        $this->order->update(['payment_status' => 'failed']);
        
        // Send notification to customer service for manual review
        NotifyCustomerService::dispatch($this->order, $exception);
    }
}

// The job chain setup
class OrderController extends Controller
{
    public function processOrder(Order $order)
    {
        Bus::chain([
            new ProcessOrderPayment($order),
            new UpdateInventoryLevels($order),
            new SendOrderConfirmation($order),
            new ScheduleShipment($order),
        ])->dispatch();

        return response()->json([
            'message' => 'Order processing started',
            'order_id' => $order->id
        ]);
    }
}

// Supporting exception classes
class PaymentGatewayDownException extends Exception
{
    public function isPermanentFailure(): bool
    {
        return str_contains($this->getMessage(), 'permanently disabled') ||
               str_contains($this->getMessage(), 'account terminated');
    }
}

class CustomerAccountSuspendedException extends Exception {}
class InventorySystemException extends Exception {}
class TemporaryApiException extends Exception {}
class CustomerDeletedException extends Exception {}

In this example, the failWhen() method ensures that when critical exceptions occur (like suspended customer accounts or inventory system failures), the entire job chain stops executing. This prevents scenarios where payment fails but inventory is still decremented, or where confirmation emails are sent for failed orders.

The key distinction is:

  • deleteWhen(): Removes the job entirely, chain continues
  • failWhen(): Marks job as failed, chain stops, failure is recorded for debugging

This approach provides better traceability for troubleshooting while maintaining the protective behavior needed for job chains. The failed job remains in the system for analysis, and you can still implement custom failure handling through the failed() method.

The failWhen() method accepts both exception classes and closures, giving you fine-grained control over which exceptions should halt chain execution versus which should allow retries or deletions.


Stay Updated with More Laravel Tips

Enjoyed this article? There's plenty more where that came from! Subscribe to our channels to stay updated with the latest Laravel tips, tricks, and best practices:

Subscribe to Harris Raftopoulos

Don’t miss out on the latest issues. Sign up now to get access to the library of members-only issues.
jamie@example.com
Subscribe