Skill
Community

error-handling

Install
1
Install the plugin
$
npx claudepluginhub bramato/laravel-react-plugins --plugin laravel-react

Want just this skill?

Then install: npx claudepluginhub u/[userId]/[slug]

Description

Use when implementing error handling, exception management, error boundaries, or structured error responses across Laravel and React. Covers custom exceptions, API error formats, React Error Boundaries, form validation errors, and logging strategies.

Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Error Handling — Laravel + Inertia + React

Comprehensive patterns for handling errors across the full stack: Laravel exception handling, custom domain exceptions, API error formats, React Error Boundaries, Inertia error handling, and logging strategies.

1. Laravel Exception Handler Customization

Laravel 11+ uses bootstrap/app.php for exception handling configuration. Earlier versions use app/Exceptions/Handler.php.

Laravel 11+ Configuration

// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions) {

        // Render custom responses for specific exceptions
        $exceptions->render(function (NotFoundHttpException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json([
                    'error'   => 'resource_not_found',
                    'message' => 'The requested resource was not found.',
                ], 404);
            }
        });

        // Handle domain exceptions
        $exceptions->render(function (OrderNotCancellableException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json([
                    'error'   => 'order_not_cancellable',
                    'message' => $e->getMessage(),
                ], 422);
            }

            return back()->with('error', $e->getMessage());
        });

        // Report to external services
        $exceptions->report(function (OrderNotCancellableException $e) {
            // ~~monitoring: Log to Sentry/Bugsnag
            // Sentry::captureException($e);
            logger()->warning('Order cancellation attempted', [
                'order_id' => $e->orderId,
            ]);
        });

        // Don't report certain exceptions
        $exceptions->dontReport([
            OrderNotCancellableException::class,
            InsufficientInventoryException::class,
        ]);

    })->create();

Laravel 10 Handler Pattern

// app/Exceptions/Handler.php
namespace App\Exceptions;

use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;

class Handler extends ExceptionHandler
{
    protected $dontReport = [
        OrderNotCancellableException::class,
    ];

    public function register(): void
    {
        $this->renderable(function (NotFoundHttpException $e, Request $request) {
            if ($request->is('api/*')) {
                return response()->json([
                    'error'   => 'resource_not_found',
                    'message' => 'The requested resource was not found.',
                ], 404);
            }
        });

        $this->reportable(function (Throwable $e) {
            // ~~monitoring: Sentry integration
            // if (app()->bound('sentry')) {
            //     app('sentry')->captureException($e);
            // }
        });
    }
}

2. Custom Domain Exceptions

Create specific exception classes for domain-level error conditions. These make error handling explicit and testable.

Base Domain Exception

<?php

namespace App\Exceptions;

use RuntimeException;

abstract class DomainException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly ?string $errorCode = null,
        public readonly array $context = [],
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $code, $previous);
    }

    /**
     * HTTP status code for this exception when rendered as a response.
     */
    public function statusCode(): int
    {
        return 422;
    }

    /**
     * Structured error payload for API responses.
     */
    public function toArray(): array
    {
        return [
            'error'   => $this->errorCode ?? 'domain_error',
            'message' => $this->getMessage(),
        ];
    }
}

Specific Domain Exceptions

<?php

namespace App\Exceptions;

use App\Models\Order;

class OrderNotCancellableException extends DomainException
{
    public readonly int $orderId;

    public static function forOrder(Order $order): self
    {
        $instance = new self(
            message: "Order #{$order->id} with status '{$order->status->label()}' cannot be cancelled.",
            errorCode: 'order_not_cancellable',
            context: ['order_id' => $order->id, 'status' => $order->status->value],
        );
        $instance->orderId = $order->id;

        return $instance;
    }

    public function statusCode(): int
    {
        return 422;
    }
}
<?php

namespace App\Exceptions;

class InsufficientInventoryException extends DomainException
{
    public static function forProduct(int $productId, int $requested, int $available): self
    {
        return new self(
            message: "Insufficient inventory for product #{$productId}. Requested: {$requested}, Available: {$available}.",
            errorCode: 'insufficient_inventory',
            context: compact('productId', 'requested', 'available'),
        );
    }

    public function statusCode(): int
    {
        return 409;
    }
}
<?php

namespace App\Exceptions;

class PaymentFailedException extends DomainException
{
    public static function declined(string $reason): self
    {
        return new self(
            message: "Payment was declined: {$reason}",
            errorCode: 'payment_declined',
            context: ['reason' => $reason],
        );
    }

    public static function gatewayTimeout(): self
    {
        return new self(
            message: 'Payment gateway did not respond in time. Please try again.',
            errorCode: 'payment_gateway_timeout',
        );
    }

    public function statusCode(): int
    {
        return 402;
    }
}

Using Domain Exceptions in Services

class OrderService
{
    public function cancel(Order $order): Order
    {
        if (! $order->status->isCancellable()) {
            throw OrderNotCancellableException::forOrder($order);
        }

        return DB::transaction(function () use ($order) {
            $order->update(['status' => OrderStatus::Cancelled]);
            event(new OrderCancelled($order));
            return $order->fresh();
        });
    }
}

3. API Error Response Format

Maintain a consistent JSON error structure across the entire API.

Standard Error Response Structure

{
    "error": "error_code_snake_case",
    "message": "Human-readable description of what went wrong.",
    "errors": {
        "field_name": ["Validation message for this field."]
    },
    "meta": {
        "request_id": "uuid-here",
        "timestamp": "2025-01-15T10:30:00Z"
    }
}

Error Response Helper Trait

<?php

namespace App\Http\Traits;

use Illuminate\Http\JsonResponse;

trait ApiResponses
{
    protected function errorResponse(
        string $error,
        string $message,
        int $status = 400,
        array $errors = [],
    ): JsonResponse {
        $payload = [
            'error'   => $error,
            'message' => $message,
        ];

        if (! empty($errors)) {
            $payload['errors'] = $errors;
        }

        return response()->json($payload, $status);
    }

    protected function validationErrorResponse(array $errors): JsonResponse
    {
        return $this->errorResponse(
            error: 'validation_error',
            message: 'The given data was invalid.',
            status: 422,
            errors: $errors,
        );
    }

    protected function notFoundResponse(string $message = 'Resource not found.'): JsonResponse
    {
        return $this->errorResponse('not_found', $message, 404);
    }

    protected function unauthorizedResponse(string $message = 'Unauthorized.'): JsonResponse
    {
        return $this->errorResponse('unauthorized', $message, 401);
    }

    protected function forbiddenResponse(string $message = 'Forbidden.'): JsonResponse
    {
        return $this->errorResponse('forbidden', $message, 403);
    }
}

HTTP Status Code Mapping

Status CodeError CodeWhen to Use
400bad_requestMalformed request, missing required headers
401unauthorizedMissing or invalid authentication
402payment_requiredPayment failed or required
403forbiddenAuthenticated but not authorized
404not_foundResource does not exist
409conflictState conflict (e.g., duplicate, insufficient inventory)
422validation_errorValidation failed, domain rule violation
423lockedResource is locked for editing
429rate_limitedToo many requests
500server_errorUnexpected server error
503service_unavailableMaintenance or dependency outage

4. Validation Error Handling

FormRequest Validation with Inertia

Inertia automatically handles validation errors. When a FormRequest fails, Laravel redirects back with errors in the session. Inertia picks these up and makes them available via usePage().props.errors.

// app/Http/Requests/Order/StoreOrderRequest.php
class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_name'  => ['required', 'string', 'max:255'],
            'customer_email' => ['required', 'email', 'max:255'],
            'items'          => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.quantity'   => ['required', 'integer', 'min:1'],
        ];
    }

    public function messages(): array
    {
        return [
            'items.required' => 'At least one item is required.',
            'items.min'      => 'At least one item is required.',
            'items.*.product_id.exists' => 'The selected product does not exist.',
        ];
    }
}

Form Error Display in React (useForm)

import { useForm } from '@inertiajs/react';

export default function CreateOrder() {
    const { data, setData, post, processing, errors, reset } = useForm({
        customer_name: '',
        customer_email: '',
        items: [{ product_id: '', quantity: 1 }],
    });

    const handleSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        post(route('orders.store'), {
            onSuccess: () => reset(),
        });
    };

    return (
        <form onSubmit={handleSubmit}>
            <div>
                <label htmlFor="customer_name">Customer Name</label>
                <input
                    id="customer_name"
                    value={data.customer_name}
                    onChange={e => setData('customer_name', e.target.value)}
                    className={errors.customer_name ? 'border-red-500' : ''}
                />
                {errors.customer_name && (
                    <p className="mt-1 text-sm text-red-600">{errors.customer_name}</p>
                )}
            </div>

            <div>
                <label htmlFor="customer_email">Email</label>
                <input
                    id="customer_email"
                    type="email"
                    value={data.customer_email}
                    onChange={e => setData('customer_email', e.target.value)}
                    className={errors.customer_email ? 'border-red-500' : ''}
                />
                {errors.customer_email && (
                    <p className="mt-1 text-sm text-red-600">{errors.customer_email}</p>
                )}
            </div>

            <button type="submit" disabled={processing}>
                {processing ? 'Creating...' : 'Create Order'}
            </button>
        </form>
    );
}

Reusable InputError Component

// resources/js/Components/InputError.tsx
interface Props {
    message?: string;
    className?: string;
}

export default function InputError({ message, className = '' }: Props) {
    if (!message) return null;

    return (
        <p className={`text-sm text-red-600 ${className}`}>
            {message}
        </p>
    );
}

// Usage:
<InputError message={errors.customer_name} className="mt-1" />

5. Database Transaction Error Handling

Wrapping Service Operations in Transactions

use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;

class OrderService
{
    public function create(OrderDTO $dto): Order
    {
        try {
            return DB::transaction(function () use ($dto) {
                $order = Order::create($dto->toArray());

                foreach ($dto->items as $item) {
                    $order->items()->create($item->toArray());
                    $this->decrementInventory($item->productId, $item->quantity);
                }

                event(new OrderCreated($order));

                return $order->fresh(['items']);
            });
        } catch (QueryException $e) {
            // Handle specific database errors
            if ($e->getCode() === '23000') { // Integrity constraint violation
                throw new DomainException(
                    'A database constraint was violated. Please check your data.',
                    'constraint_violation',
                );
            }
            throw $e; // Re-throw unexpected database errors
        }
    }

    private function decrementInventory(int $productId, int $quantity): void
    {
        $affected = Product::where('id', $productId)
            ->where('stock', '>=', $quantity)
            ->decrement('stock', $quantity);

        if ($affected === 0) {
            $product = Product::find($productId);
            throw InsufficientInventoryException::forProduct(
                $productId,
                $quantity,
                $product->stock ?? 0,
            );
        }
    }
}

Deadlock Retry

use Illuminate\Support\Facades\DB;

// Laravel handles deadlock retries automatically with the attempts parameter:
DB::transaction(function () {
    // Critical section
    Order::lockForUpdate()->find($orderId);
    // ...
}, attempts: 3); // Retry up to 3 times on deadlock

6. React Error Boundaries

Class Component Error Boundary

// resources/js/Components/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';

interface Props {
    children: ReactNode;
    fallback?: ReactNode;
    onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

interface State {
    hasError: boolean;
    error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
    constructor(props: Props) {
        super(props);
        this.state = { hasError: false, error: null };
    }

    static getDerivedStateFromError(error: Error): State {
        return { hasError: true, error };
    }

    componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
        console.error('ErrorBoundary caught:', error, errorInfo);

        // ~~monitoring: Report to Sentry/Bugsnag
        // Sentry.captureException(error, { extra: errorInfo });

        this.props.onError?.(error, errorInfo);
    }

    render() {
        if (this.state.hasError) {
            return this.props.fallback ?? (
                <div className="flex flex-col items-center justify-center min-h-[400px] p-8">
                    <h2 className="text-xl font-semibold text-gray-900">Something went wrong</h2>
                    <p className="mt-2 text-gray-600">
                        An unexpected error occurred. Please try refreshing the page.
                    </p>
                    <button
                        onClick={() => this.setState({ hasError: false, error: null })}
                        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
                    >
                        Try Again
                    </button>
                </div>
            );
        }

        return this.props.children;
    }
}

Functional Wrapper Pattern

// resources/js/hooks/useErrorHandler.ts
import { useCallback, useState } from 'react';

export function useErrorHandler() {
    const [error, setError] = useState<Error | null>(null);

    const handleError = useCallback((error: unknown) => {
        const normalizedError = error instanceof Error
            ? error
            : new Error(String(error));

        setError(normalizedError);

        // ~~monitoring: Report to external service
        console.error('Caught error:', normalizedError);
    }, []);

    const clearError = useCallback(() => setError(null), []);

    return { error, handleError, clearError };
}

Using Error Boundaries in Layouts

// resources/js/Layouts/AuthenticatedLayout.tsx
import { ErrorBoundary } from '@/Components/ErrorBoundary';

export default function AuthenticatedLayout({ children }: { children: React.ReactNode }) {
    return (
        <div className="min-h-screen bg-gray-100">
            <nav>{/* Navigation */}</nav>

            <ErrorBoundary
                fallback={
                    <div className="max-w-7xl mx-auto py-12 px-4 text-center">
                        <h2 className="text-2xl font-bold">Page Error</h2>
                        <p className="mt-2 text-gray-600">
                            This section encountered an error. Your data is safe.
                        </p>
                        <a href="/" className="mt-4 inline-block text-blue-600 hover:underline">
                            Return to Dashboard
                        </a>
                    </div>
                }
            >
                <main className="max-w-7xl mx-auto py-6 px-4">{children}</main>
            </ErrorBoundary>
        </div>
    );
}

7. Inertia.js Error Handling

Inertia Error Pages

Configure Inertia to render custom error pages for HTTP errors:

// bootstrap/app.php (Laravel 11+)
use Inertia\Inertia;
use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->respond(function (Response $response) {
        $status = $response->getStatusCode();

        if (in_array($status, [403, 404, 500, 503])) {
            return Inertia::render('Error', [
                'status' => $status,
                'message' => match ($status) {
                    403 => 'You are not authorized to access this page.',
                    404 => 'The page you are looking for could not be found.',
                    500 => 'An unexpected server error occurred.',
                    503 => 'The service is temporarily unavailable.',
                },
            ])->toResponse(request())->setStatusCode($status);
        }

        return $response;
    });
})

Error Page Component

// resources/js/Pages/Error.tsx
import { Head } from '@inertiajs/react';

interface Props {
    status: number;
    message: string;
}

export default function Error({ status, message }: Props) {
    const titles: Record<number, string> = {
        403: 'Forbidden',
        404: 'Not Found',
        500: 'Server Error',
        503: 'Service Unavailable',
    };

    return (
        <>
            <Head title={`${status} - ${titles[status] ?? 'Error'}`} />
            <div className="flex min-h-screen items-center justify-center">
                <div className="text-center">
                    <h1 className="text-6xl font-bold text-gray-300">{status}</h1>
                    <h2 className="mt-4 text-2xl font-semibold text-gray-900">
                        {titles[status] ?? 'Error'}
                    </h2>
                    <p className="mt-2 text-gray-600">{message}</p>
                    <a
                        href="/"
                        className="mt-6 inline-block px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
                    >
                        Go Home
                    </a>
                </div>
            </div>
        </>
    );
}

Global Inertia Error Listener

// resources/js/app.tsx
import { router } from '@inertiajs/react';

// Listen for global Inertia errors (network failures, unexpected responses)
router.on('error', (event) => {
    console.error('Inertia error:', event);
    // ~~monitoring: Report to error tracking service
});

// Handle invalid responses (e.g., non-Inertia response from server)
router.on('invalid', (event) => {
    event.preventDefault();
    console.error('Invalid Inertia response. Reloading page.');
    window.location.reload();
});

// Handle exceptions during visit
router.on('exception', (event) => {
    event.preventDefault();
    console.error('Inertia exception:', event.detail.exception);
});

Flash Error Display

// resources/js/Components/FlashMessages.tsx
import { usePage } from '@inertiajs/react';
import { useEffect, useState } from 'react';

interface SharedProps {
    flash: {
        success?: string;
        error?: string;
    };
}

export default function FlashMessages() {
    const { flash } = usePage<SharedProps>().props;
    const [visible, setVisible] = useState(false);

    useEffect(() => {
        if (flash.success || flash.error) {
            setVisible(true);
            const timer = setTimeout(() => setVisible(false), 5000);
            return () => clearTimeout(timer);
        }
    }, [flash]);

    if (!visible || (!flash.success && !flash.error)) return null;

    return (
        <div className="fixed top-4 right-4 z-50">
            {flash.success && (
                <div className="rounded-lg bg-green-50 p-4 border border-green-200">
                    <p className="text-sm text-green-800">{flash.success}</p>
                </div>
            )}
            {flash.error && (
                <div className="rounded-lg bg-red-50 p-4 border border-red-200">
                    <p className="text-sm text-red-800">{flash.error}</p>
                </div>
            )}
        </div>
    );
}

8. Logging Strategies

Channel Configuration

// config/logging.php
'channels' => [
    'stack' => [
        'driver' => 'stack',
        'channels' => ['daily', 'slack'],
        'ignore_exceptions' => false,
    ],

    'daily' => [
        'driver' => 'daily',
        'path' => storage_path('logs/laravel.log'),
        'level' => 'debug',
        'days' => 14,
    ],

    'orders' => [
        'driver' => 'daily',
        'path' => storage_path('logs/orders.log'),
        'level' => 'info',
        'days' => 30,
    ],

    'slack' => [
        'driver' => 'slack',
        'url' => env('LOG_SLACK_WEBHOOK_URL'),
        'level' => 'critical',
        'username' => 'Laravel Log',
    ],

    'stderr' => [
        'driver' => 'monolog',
        'level' => 'debug',
        'handler' => StreamHandler::class,
        'with' => ['stream' => 'php://stderr'],
    ],
],

Structured Logging with Context

use Illuminate\Support\Facades\Log;

// Basic contextual logging
Log::info('Order created', [
    'order_id'  => $order->id,
    'user_id'   => $order->user_id,
    'total'     => $order->total,
]);

// Channel-specific logging
Log::channel('orders')->info('Order shipped', [
    'order_id'   => $order->id,
    'carrier'    => $carrier,
    'tracking'   => $trackingNumber,
]);

// Multi-channel logging
Log::stack(['daily', 'slack'])->critical('Payment gateway failure', [
    'gateway'    => 'stripe',
    'order_id'   => $order->id,
    'error'      => $exception->getMessage(),
]);

// Log with exception context
try {
    $this->processPayment($order);
} catch (PaymentFailedException $e) {
    Log::error('Payment processing failed', [
        'order_id'   => $order->id,
        'error_code' => $e->errorCode,
        'context'    => $e->context,
        'exception'  => $e,
    ]);
    throw $e;
}

Adding Global Log Context

// In a middleware or ServiceProvider:
use Illuminate\Support\Facades\Log;

Log::shareContext([
    'request_id' => request()->header('X-Request-ID', (string) Str::uuid()),
    'user_id'    => auth()->id(),
    'ip'         => request()->ip(),
]);

9. Error Monitoring Integration

Sentry/Bugsnag Placeholder Pattern

// ~~monitoring: Replace these placeholders with your monitoring service

// In bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions) {
    $exceptions->report(function (Throwable $e) {
        // ~~monitoring: Sentry integration
        // if (app()->bound('sentry')) {
        //     app('sentry')->captureException($e);
        // }

        // ~~monitoring: Bugsnag integration
        // if (app()->bound('bugsnag')) {
        //     app('bugsnag')->notifyException($e);
        // }
    });
})
// ~~monitoring: Frontend error tracking in React ErrorBoundary
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
    // ~~monitoring: Sentry browser integration
    // Sentry.captureException(error, { extra: { componentStack: errorInfo.componentStack } });

    // ~~monitoring: Bugsnag browser integration
    // Bugsnag.notify(error, (event) => {
    //     event.addMetadata('react', { componentStack: errorInfo.componentStack });
    // });
}

10. Testing Error Scenarios

Testing Domain Exception Throwing

use App\Exceptions\OrderNotCancellableException;
use App\Enums\OrderStatus;
use App\Models\Order;

it('throws when cancelling a shipped order', function () {
    $order = Order::factory()->shipped()->create();

    $this->service->cancel($order);
})->throws(OrderNotCancellableException::class);

it('includes order id in exception', function () {
    $order = Order::factory()->shipped()->create();

    try {
        $this->service->cancel($order);
        $this->fail('Expected exception was not thrown.');
    } catch (OrderNotCancellableException $e) {
        expect($e->orderId)->toBe($order->id);
        expect($e->getMessage())->toContain((string) $order->id);
    }
});

Testing API Error Responses

it('returns 404 for nonexistent order', function () {
    $this->actingAs(User::factory()->create())
        ->getJson('/api/orders/99999')
        ->assertNotFound()
        ->assertJson([
            'error'   => 'resource_not_found',
            'message' => 'The requested resource was not found.',
        ]);
});

it('returns 422 with validation errors', function () {
    $this->actingAs(User::factory()->create())
        ->postJson('/api/orders', [])
        ->assertUnprocessable()
        ->assertJsonValidationErrors(['customer_name', 'items']);
});

it('returns 403 for unauthorized access', function () {
    $order = Order::factory()->create();

    $this->actingAs(User::factory()->create())
        ->getJson("/api/orders/{$order->id}")
        ->assertForbidden();
});

Testing Transaction Rollback

it('rolls back on inventory failure', function () {
    $product = Product::factory()->create(['stock' => 1]);

    $dto = new OrderDTO(
        // ...order data with quantity: 10 (more than available)
    );

    expect(fn () => $this->service->create($dto))
        ->toThrow(InsufficientInventoryException::class);

    // Verify no order was created (transaction rolled back)
    $this->assertDatabaseCount('orders', 0);

    // Verify stock was not decremented
    expect($product->fresh()->stock)->toBe(1);
});

Testing React Error Boundary

import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from '@/Components/ErrorBoundary';

const ThrowingComponent = () => {
    throw new Error('Test error');
};

it('renders fallback on error', () => {
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});

    render(
        <ErrorBoundary fallback={<div>Something went wrong</div>}>
            <ThrowingComponent />
        </ErrorBoundary>
    );

    expect(screen.getByText('Something went wrong')).toBeInTheDocument();
    consoleSpy.mockRestore();
});

it('calls onError callback', () => {
    const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
    const onError = vi.fn();

    render(
        <ErrorBoundary onError={onError}>
            <ThrowingComponent />
        </ErrorBoundary>
    );

    expect(onError).toHaveBeenCalledWith(
        expect.any(Error),
        expect.objectContaining({ componentStack: expect.any(String) })
    );
    consoleSpy.mockRestore();
});
Stats
Stars0
Forks0
Last CommitFeb 8, 2026

Similar Skills