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
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
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
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
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
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
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
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
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
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.