Error Handling as a Laravel Design Concern

9 min read

Error handling is API design. A mobile client must know whether it can retry; a partner needs a stable explanation of a rejected request; an operator needs the diagnostics without giving them to the caller. Those are incompatible needs if every controller catches Throwable and returns whatever message it received.

The useful distinction is between an expected domain refusal and an unexpected fault. A shipped order cannot be cancelled: that is a product rule with a predictable client outcome. A database timeout while saving that cancellation is an operational failure: report it with context, but do not turn its SQL text into an API response. This article builds that boundary for a Laravel JSON API with named domain exceptions, one Problem Details envelope, and contract tests.

Begin with failure modes, not a catch-all

An order-cancellation endpoint has at least three outcomes: malformed input is validation, a shipped order is a known refusal, and a broken dependency is not a client instruction. A controller-level try/catch flattens those outcomes and forces every endpoint to choose status, wording, logs, and privacy policy.

Give the domain an explicit vocabulary instead. This base class knows nothing about HTTP, Eloquent, controllers, or translation. It exposes exactly what the HTTP boundary needs to render a durable public problem.

php
<?php

declare(strict_types=1);

namespace App\Domain\Orders\Exceptions;

use RuntimeException;

abstract class DomainRuleViolation extends RuntimeException
{
    abstract public function problemType(): string;

    abstract public function publicTitle(): string;

    abstract public function publicDetail(): string;

    abstract public function statusCode(): int;
}
php
<?php

declare(strict_types=1);

namespace App\Domain\Orders\Exceptions;

final class OrderAlreadyShipped extends DomainRuleViolation
{
    public function __construct(public readonly string $orderNumber)
    {
        parent::__construct("Order [{$orderNumber}] has already shipped.");
    }

    public function problemType(): string
    {
        return 'https://api.example.test/problems/order-already-shipped';
    }

    public function publicTitle(): string
    {
        return 'Order cannot be cancelled';
    }

    public function publicDetail(): string
    {
        return 'A shipped order cannot be cancelled.';
    }

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

The exception message may be useful in protected logs; it is not automatically safe client copy. That separation prevents an internal identifier, a provider response, or a diagnostic hint from becoming a public contract by accident.

Throw the rule where the transition belongs

The application service coordinates persistence. The aggregate owns its own invariants. It should not return a partly changed object and hope a controller spots the invalid state later.

php
<?php

declare(strict_types=1);

namespace App\Domain\Orders;

use App\Domain\Orders\Exceptions\OrderAlreadyShipped;
use DateTimeImmutable;

final class Order
{
    public function __construct(
        public readonly string $number,
        private OrderStatus $status,
        private ?DateTimeImmutable $cancelledAt = null,
    ) {}

    public function cancel(DateTimeImmutable $now): void
    {
        if ($this->status === OrderStatus::Shipped) {
            throw new OrderAlreadyShipped($this->number);
        }

        if ($this->status === OrderStatus::Cancelled) {
            return;
        }

        $this->status = OrderStatus::Cancelled;
        $this->cancelledAt = $now;
    }
}
php
<?php

declare(strict_types=1);

namespace App\Application\Orders;

use App\Domain\Orders\Order;
use DateTimeImmutable;

interface OrderRepository
{
    public function getByNumber(string $number): Order;

    public function save(Order $order): void;
}

final class CancelOrder
{
    public function __construct(private readonly OrderRepository $orders) {}

    public function handle(string $number, DateTimeImmutable $now): void
    {
        $order = $this->orders->getByNumber($number);
        $order->cancel($now);
        $this->orders->save($order);
    }
}

Whether a missing order is 404 or a privacy-preserving 403 is a product decision. Name it as another domain exception only if a client can safely act on that distinction. Do not throw an HttpException from the aggregate just because HTTP is the first delivery channel.

Establish one JSON envelope at the edge

RFC 9457 provides a useful public vocabulary: type, title, status, and detail. Add an opaque request ID so support can correlate a report without showing a trace. A tiny factory keeps this shape consistent across endpoints.

php
<?php

declare(strict_types=1);

namespace App\Http\Api;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

final class ProblemDetails
{
    /** @param array<string, list<string>> $errors */
    public static function response(
        Request $request,
        string $type,
        string $title,
        int $status,
        string $detail,
        array $errors = [],
    ): JsonResponse {
        $body = [
            'type' => $type,
            'title' => $title,
            'status' => $status,
            'detail' => $detail,
            'instance' => $request->path(),
            'request_id' => (string) $request->header('X-Request-Id', (string) Str::uuid()),
        ];

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

        return response()->json(
            data: $body,
            status: $status,
            headers: ['Content-Type' => 'application/problem+json'],
        );
    }
}

type is a stable machine identifier, not a URL improvised from a PHP class. Clients may branch on it, so a namespace rename must not become a breaking API change. detail is human-facing and safe. Neither belongs to an email address, payment token, SQL statement, stack trace, or raw upstream response.

Render expected failures in one place

Laravel's exception configuration keeps the translation at the HTTP boundary. Return null for non-JSON requests: a server-rendered form then keeps Laravel's normal redirects and error views instead of receiving an API document.

php
<?php

declare(strict_types=1);

use App\Domain\Orders\Exceptions\DomainRuleViolation;
use App\Http\Api\ProblemDetails;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(web: __DIR__.'/../routes/web.php', api: __DIR__.'/../routes/api.php')
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->render(function (DomainRuleViolation $exception, Request $request) {
            if (! $request->expectsJson()) {
                return null;
            }

            return ProblemDetails::response(
                request: $request,
                type: $exception->problemType(),
                title: $exception->publicTitle(),
                status: $exception->statusCode(),
                detail: $exception->publicDetail(),
            );
        });

        $exceptions->render(function (ValidationException $exception, Request $request) {
            if (! $request->expectsJson()) {
                return null;
            }

            return ProblemDetails::response(
                request: $request,
                type: 'https://api.example.test/problems/validation-failed',
                title: 'Validation failed',
                status: 422,
                detail: 'One or more fields need attention.',
                errors: $exception->errors(),
            );
        });
    })
    ->create();

For a public API, middleware that enforces JSON negotiation on api/* can be clearer than relying on every consumer's Accept header. Pick one rule, document it, and test it. Keep framework defaults for authentication and rate limits aligned too; a beautifully rendered domain error is not enough if those paths return a competing schema.

Report the fault, not the customer's data

Known domain refusals are normally not alerts. An unavailable payment provider is. Reporting context must be allowlisted: a provider name and internal tenant UUID may be legitimate; request bodies, authorization headers, addresses, and card data are not.

php
<?php

declare(strict_types=1);

namespace App\Infrastructure\Payments;

use RuntimeException;

final class PaymentProviderUnavailable extends RuntimeException
{
    public function __construct(
        public readonly string $providerName,
        public readonly string $operation,
    ) {
        parent::__construct("Payment provider [{$providerName}] is unavailable.");
    }
}
php
<?php

declare(strict_types=1);

use App\Infrastructure\Payments\PaymentProviderUnavailable;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

return function (Exceptions $exceptions): void {
    $exceptions->report(function (PaymentProviderUnavailable $exception): void {
        Log::warning('Payment provider unavailable', [
            'provider' => $exception->providerName,
            'operation' => $exception->operation,
        ]);
    });

    $exceptions->context(function (): array {
        $request = app(Request::class);

        return [
            'request_id' => $request->header('X-Request-Id'),
            'tenant_id' => $request->user()?->tenant_id,
        ];
    });
};

Place that code inside the existing withExceptions closure in a real bootstrap/app.php; the isolated example only highlights the reporting policy. Let Laravel's configured channel send reports to GlitchTip or Sentry. A second, direct SDK call creates duplicate incidents. For a widespread outage, throttle reports or configure grouping and rate limits so monitoring remains useful.

Test the API contract rather than implementation details

The renderer may move from an exception's render() method to a central closure. The status and type a consumer sees must not change unnoticed.

php
<?php

declare(strict_types=1);

use App\Application\Orders\CancelOrder;
use App\Domain\Orders\Exceptions\OrderAlreadyShipped;
use Mockery\MockInterface;

it('returns a stable problem document for a shipped order', function (): void {
    $this->mock(CancelOrder::class, function (MockInterface $service): void {
        $service->shouldReceive('handle')
            ->once()
            ->andThrow(new OrderAlreadyShipped('ORD-1042'));
    });

    $this->postJson('/api/orders/ORD-1042/cancellation')
        ->assertConflict()
        ->assertHeader('Content-Type', 'application/problem+json')
        ->assertJsonPath('type', 'https://api.example.test/problems/order-already-shipped')
        ->assertJsonPath('title', 'Order cannot be cancelled')
        ->assertJsonPath('status', 409)
        ->assertJsonPath('request_id', fn (mixed $value): bool => is_string($value));
});

Cover validation, unauthenticated and forbidden access, missing resources, and a generic production 500 too. The final test should assert that the body has no trace, database message, or provider text. A secret leaked only on an edge case is still a production security bug.

Contrast with the shortcuts

Returning ['error' => 'Something went wrong'] from every controller is easy until a client needs a field map, status, or a way to distinguish retryable conflict from permanent refusal. Throwing abort(422) throughout the domain has the inverse cost: queue workers and CLI commands inherit HTTP semantics.

A universal ApiException with arbitrary status and payload can work at a tiny integration boundary. It often degenerates into an untyped bag in which every layer invents public errors. Named domain violations are more deliberate: they document which business outcomes the product actually supports.

When not to use this

Do not create a custom exception for every invalid form field. Laravel validation already models that category and exposes field-level errors. Do not wrap every vendor exception to make a hierarchy look complete; wrap an integration failure only when callers need retry, fallback, or an operator signal.

For a server-rendered application without external API consumers, Laravel's standard validation redirects and error views may be the right contract. A Problem Details layer adds upkeep when no client benefits. In a single internal command, a typed result object can also be clearer than exceptions for a normal expected branch.

Production pitfalls

Reverse proxies can replace error bodies, rate limiters can use another JSON shape, and authentication can fail before the controller. Exercise every path in staging with the production proxy and reporting configuration. Do not trust a caller-supplied X-Request-Id without validation; generate or replace it at trusted ingress. Do not forward raw request bodies “temporarily” to monitoring.

Finally, avoid treating every 5xx as an expected detailed problem. Generic safe copy plus a correlation ID is enough for a client. Full diagnostics belong in protected logs and alerts. That boundary is not decorative formatting: it makes product rules explicit, lets clients act safely, and gives operators the information required to repair real faults.

Related articles

Existing system support

Need help with a live application?

I help companies improve live systems, clean up delivery workflows, and ship new features without adding avoidable complexity.

Comments (0)
Sign in to leave a comment

You need to be signed in to add a comment.

Login

Need someone to take responsibility for the next step?

Let’s talk about your project and define a scope that actually makes sense for your goals.