Skip to main content
Laravel, shipping fast.

Chapter 5

Error Handling & Logging

Julian Beaujardin

Every API fails eventually. A validation rule rejects a payload. Statamic times out. A worker dies mid-job. The question was never whether the Webplo License API would fail, it was whether a failure would look the same way every time, or whether every controller would invent its own way of dying.

This chapter covers what actually happens when something breaks: how one exception handler shapes every failure the same way, why a status code alone isn't enough for a consumer to branch on, what a log entry needs to be worth keeping, how you trace one logical request across three services that don't share a request ID, and what you do when the thing writing your logs is the thing that's down.

An error response is part of your API's contract, not an afterthought bolted onto the happy path.

Errors are a contract, not an accident

Here's the rule: a consumer of your API should be able to write error handling once, against the shape of your errors, and never touch it again. Not once per endpoint. Not once per exception type. Once.

That only works if every failure, no matter where it originates, comes back through the same door. A ValidationException thrown in a FormRequest, a ModelNotFoundException thrown by Eloquent, a ThrottleRequestsException thrown by the rate limiter, none of these are things your controllers should ever catch by hand. If they do, you've reintroduced the exact problem Chapter 2 spent an entire chapter eliminating from your responses: every endpoint doing its own thing, differently, forever.

The Webplo License API treats errors the same way it treats successful responses: as a Responsable, wrapped, predictable, and owned by one class instead of scattered across every controller that might throw.

One handler, every failure shaped the same

Laravel 13's slim skeleton doesn't give you an app/Exceptions/Handler.php to extend anymore. There's no Handler class in api-server, api-license, or api-ai, none of the five current apps in this fleet has one. Exception handling lives entirely inside bootstrap/app.php, in a closure passed to withExceptions().

// Bad: extending a Handler class that doesn't exist in this fleet
class ApiExceptionHandler extends Handler
{
    public function render($request, Throwable $exception)
    {
        return match (true) {
            $exception instanceof ValidationException => $this->validation($exception),
            default => $this->generic($exception),
        };
    }
}

This would fatal on boot. There's no Handler to extend, no Kernel wiring it in, nowhere for Laravel to even find it. It's the pre-Laravel-11 shape, and it's gone from every app in this fleet except template-base, which never migrated off it. Writing this against api-server is writing against a framework that doesn't exist here anymore.

Here's what's actually there:

// api-server/bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
    // Report exceptions to Nightwatch for error tracking
    $exceptions->report(function (Throwable $exception) {
        try {
            if ($exception instanceof PollingRetryException) {
                // silent: expected transient failure, retries handle it
            } elseif ($exception instanceof ThrottleRequestsException) {
                Nightwatch::warning('Rate limit exceeded', [
                    'exception' => class_basename($exception),
                ]);
            } elseif ($exception instanceof ValidationException) {
                Nightwatch::info('Validation failed', [
                    'errors' => $exception->errors(),
                ]);
            } elseif ($exception instanceof ModelNotFoundException) {
                Nightwatch::warning('Resource not found', [
                    'exception' => class_basename($exception),
                ]);
            } else {
                Nightwatch::captureException($exception);
            }
        } catch (Throwable) {
            // Nightwatch not available or container not initialized
        }
    });

    $exceptions->render(
        using: fn (Throwable $exception) => ApiExceptionRenderer::render($exception),
    );
})->create();

Two closures, two jobs. report() is meant to decide how loudly an exception gets recorded, a ThrottleRequestsException as a warning, a bare ValidationException as barely more than info, anything unclassified escalated as a real error. Check that assumption before you trust it, though: the Nightwatch package this fleet has installed exposes exactly one reporting method on its facade, report(Throwable $e, bool|null $handled = null). warning(), info(), and captureException() aren't on it. Call a method that doesn't exist and PHP throws, the catch (Throwable) wrapping this closure swallows it, and the exception you were trying to classify never reaches Nightwatch at all, at any severity. render() decides what the client sees, and it delegates entirely to a shared class:

// api-infrastructure/src/Exceptions/ApiExceptionRenderer.php
final readonly class ApiExceptionRenderer
{
    public static function render(Throwable $exception): HttpResponse
    {
        $status = HttpResponse::HTTP_INTERNAL_SERVER_ERROR;
        $message = $exception->getMessage();

        if ($exception instanceof ThrottleRequestsException) {
            $status = HttpResponse::HTTP_TOO_MANY_REQUESTS;
            $message = 'Too many requests. Please try again later.';
        } elseif ($exception instanceof ValidationException) {
            $status = HttpResponse::HTTP_UNPROCESSABLE_ENTITY;
            $message = json_encode($exception->errors()) ?: '{}';
        } elseif ($exception instanceof AuthenticationException) {
            $status = HttpResponse::HTTP_UNAUTHORIZED;
            $message = 'Unauthenticated.';
        }
        // ...

        return (new ErrorResponse(message: $message, status: $status))
            ->toResponse(request());
    }
}

ApiExceptionRenderer lives in the shared package, not copied into every app. api-server, api-license, and api-ai all call the same static method, so a fix to how ModelNotFoundException gets rendered fixes it everywhere at once, not in three places that will inevitably drift.

ErrorResponse extends BaseResponse, the same abstract Responsable you saw in Chapter 2 for ModelResponse and CollectionResponse. It only implements getData(), everything else about turning that array into a real JsonResponse with the right headers is inherited, not repeated.

Not every exception wants to be reported. PollingRetryException in api-server implements Laravel's own ShouldntReport contract:

// api-server/app/Exceptions/PollingRetryException.php
class PollingRetryException extends Exception implements ShouldntReport {}

That single interface keeps expected, retryable failures out of your error tracker entirely, no if statement required in the report closure, no noise drowning out the exceptions that actually need a human. api-license goes one step further and adds a type-specific render for a downstream failure that isn't really a server error at all:

// api-license/bootstrap/app.php
$exceptions->dontReport(ConnectionException::class);

$exceptions->render(function (ConnectionException $exception) {
    return response()->json(
        data: ['errors' => [$exception->getMessage()]],
        status: HttpResponse::HTTP_GATEWAY_TIMEOUT,
    );
});

A timeout talking to Statamic isn't a bug in your code. It's a 504, not a 500, and it's not worth an alert every time Statamic is slow. One handler, applied once, and every controller in the app inherits the decision.