Obsługa błędów jako zagadnienie projektowe Laravela

8 min. czytania

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
<?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;
    }
}

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
<?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);
    }
}

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
<?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
<?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
<?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,
        ];
    });
};

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

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.