Obsługa błędów jest projektowaniem API. Klient mobilny musi wiedzieć, czy może
ponowić próbę; partner potrzebuje stabilnego wyjaśnienia odrzuconego requestu;
operator potrzebuje diagnostyki, ale bez przekazywania jej wywołującemu. Te
potrzeby są sprzeczne, gdy każdy kontroler łapie Throwable i zwraca otrzymany
komunikat.
Użyteczna granica przebiega między oczekiwaną odmową domenową a nieoczekiwaną awarią. Nie da się anulować wysłanego zamówienia: to reguła produktu z przewidywalnym wynikiem dla klienta. Timeout bazy podczas zapisu anulowania to awaria operacyjna: raportuj ją z kontekstem, ale nie zamieniaj jej tekstu SQL w odpowiedź API. Zbudujemy tę granicę dla JSON API przez nazwane wyjątki domenowe, jeden envelope Problem Details i testy kontraktu.
Zacznij od trybów porażki, nie od catch-all
Endpoint anulowania zamówienia ma przynajmniej trzy wyniki: błędne wejście to
walidacja, wysłane zamówienie to znana odmowa, a uszkodzona zależność nie jest
instrukcją dla klienta. try/catch w kontrolerze spłaszcza te przypadki i
zmusza każdy endpoint do wybierania statusu, tekstu, logów oraz polityki
prywatności.
Nadaj domenie jawne słownictwo. Ta klasa bazowa nie zna HTTP, Eloquent, kontrolerów ani tłumaczeń. Udostępnia dokładnie to, czego warstwa HTTP potrzebuje do wyrenderowania trwałego, publicznego problemu.
<?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;
}
}
Komunikat wyjątku może być użyteczny w chronionych logach; nie jest automatycznie bezpieczną treścią dla klienta. To rozdzielenie nie pozwala, by wewnętrzny identyfikator, odpowiedź dostawcy albo diagnostyczna wskazówka przez przypadek stały się publicznym kontraktem.
Rzuć regułę tam, gdzie należy przejście stanu
Application service koordynuje zapis. Aggregate jest właścicielem własnych invariantów. Nie powinien zwracać częściowo zmienionego obiektu z nadzieją, że kontroler później dostrzeże nieprawidłowy stan.
<?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);
}
}
To, czy brakujące zamówienie ma być 404, czy chroniącym prywatność 403, jest
decyzją produktową. Nazwij je kolejnym wyjątkiem domenowym tylko wtedy, gdy
klient może bezpiecznie działać na podstawie tego rozróżnienia. Nie rzucaj
HttpException z aggregate'u tylko dlatego, że HTTP jest pierwszym kanałem.
Ustal jeden envelope JSON na granicy
RFC 9457 daje użyteczne słownictwo publiczne: type, title, status i
detail. Dodaj nieprzezroczysty identyfikator requestu, aby wsparcie mogło
skorelować zgłoszenie bez pokazywania trace'a. Mała factory utrzymuje ten
kształt spójny między endpointami.
<?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 jest trwałym identyfikatorem maszynowym, a nie URL-em spontanicznie
wyprowadzonym z klasy PHP. Klient może od niego zależeć, więc zmiana namespace'u
nie może stać się łamiącą zmianą API. detail jest treścią dla człowieka i musi
być bezpieczne. Żadne z pól nie jest miejscem na e-mail, token płatniczy, SQL,
stack trace ani surową odpowiedź usługi zewnętrznej.
Renderuj oczekiwane błędy w jednym miejscu
Konfiguracja wyjątków Laravela utrzymuje tłumaczenie na granicy HTTP. Dla
requestów niebędących JSON-em zwróć null: formularz renderowany na serwerze
zachowa wtedy zwykły redirect oraz widoki błędów Laravela, a nie dokument API.
<?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();
W publicznym API middleware wymuszający negocjację JSON dla api/* może być
czytelniejszy niż poleganie na nagłówku Accept każdego konsumenta. Wybierz
jedną regułę, udokumentuj ją i przetestuj. Dopasuj też domyślne zachowanie
uwierzytelnienia i rate limitów; dobry błąd domenowy nie wystarcza, jeśli te
ścieżki zwracają konkurencyjny schemat.
Raportuj awarię, nie dane klienta
Znane odmowy domenowe zwykle nie są alertami. Niedostępny dostawca płatności już jest. Kontekst raportowania ma być allowlistą: nazwa dostawcy i wewnętrzne UUID tenanta mogą być uzasadnione; body requestu, nagłówki autoryzacji, adresy i dane kart już nie.
<?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,
];
});
};
W realnym bootstrap/app.php umieść ten kod w istniejącym closure
withExceptions; sam przykład izoluje tylko politykę raportowania. Pozwól,
by skonfigurowany kanał Laravela przesyłał raporty do GlitchTip albo Sentry.
Drugie, bezpośrednie wywołanie SDK tworzy podwójne incydenty. Przy rozległej
awarii stosuj throttling raportów albo grupowanie i limity, by monitoring
pozostał użyteczny.
Testuj kontrakt API, nie szczegóły implementacji
Renderer może przejść z metody render() wyjątku do centralnego closure. Kod
statusu i type, które widzi konsument, nie mogą zmienić się niezauważenie.
<?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));
});
Pokryj także walidację, nieuwierzytelniony i zakazany dostęp, brakujące zasoby
oraz ogólne produkcyjne 500. Ostatni test powinien potwierdzać, że body nie
zawiera trace'a, komunikatu bazy ani tekstu dostawcy. Sekret ujawniony tylko w
przypadku brzegowym nadal jest błędem bezpieczeństwa produkcji.
Kontrast z drogami na skróty
Zwracanie ['error' => 'Something went wrong'] z każdego kontrolera jest
proste, dopóki klient nie potrzebuje mapy pól, statusu albo sposobu odróżnienia
konfliktu możliwego do ponowienia od trwałej odmowy. Rzucanie abort(422) w
całej domenie ma odwrotny koszt: worker kolejki i komenda CLI dziedziczą
semantykę HTTP.
Uniwersalny ApiException z dowolnym statusem i payloadem może działać na
małej granicy integracji. Często degeneruje się jednak do nietypowanego worka,
w którym każda warstwa wymyśla publiczne błędy. Nazwane odmowy domenowe są
bardziej świadome: dokumentują wyniki biznesowe, które produkt rzeczywiście
obsługuje.
Kiedy tego NIE używać
Nie twórz własnego wyjątku dla każdego niepoprawnego pola formularza. Walidacja Laravela już modeluje tę kategorię i udostępnia błędy konkretnych pól. Nie owijaj każdego wyjątku dostawcy tylko dla kompletności hierarchii; owiń awarię integracji wyłącznie wtedy, gdy wywołujący potrzebuje retry, fallbacku albo sygnału dla operatora.
Dla aplikacji renderowanej po stronie serwera, bez zewnętrznych konsumentów API, standardowe redirecty walidacji i widoki błędów Laravela mogą być właściwym kontraktem. Warstwa Problem Details dodaje utrzymanie, gdy nikt z niej nie korzysta. W pojedynczej komendzie wewnętrznej typowany obiekt wyniku może być czytelniejszy od wyjątków dla normalnej oczekiwanej gałęzi.
Pułapki produkcyjne
Reverse proxy może podmienić body błędu, rate limiter użyć innego schematu JSON,
a uwierzytelnienie zawieść przed kontrolerem. Przećwicz wszystkie ścieżki na
stagingu z produkcyjną konfiguracją proxy i raportowania. Nie ufaj przekazanemu
przez klienta X-Request-Id bez walidacji; wygeneruj go albo podmień na
zaufanym ingressie. Nie wysyłaj surowego body requestu do monitoringu „na
chwilę”.
Na koniec, nie traktuj każdego 5xx jako oczekiwanego, szczegółowego problemu.
Bezpieczny ogólny komunikat z identyfikatorem korelacyjnym wystarczy klientowi.
Pełna diagnostyka należy do chronionych logów i alertów. Ta granica nie jest
ozdobnym formatowaniem: czyni reguły produktu jawnymi, pozwala klientom działać
bezpiecznie i daje operatorom informacje potrzebne do naprawy prawdziwych awarii.