# Exception handlers

Application exception handlers turn domain failures into deliberate HTTP
responses. Put an invokable class under `App\ExceptionHandlers`, mark it with
`#[ExceptionHandler]`, and type its single parameter as the exception it
handles:

```php
namespace App\Exceptions;

use RuntimeException;

final class OrderNotFound extends RuntimeException
{
    public function __construct(public readonly string $orderId)
    {
        parent::__construct("Order {$orderId} was not found");
    }
}
```

```php
namespace App\ExceptionHandlers;

use App\Exceptions\OrderNotFound;
use GustavPHP\Gustav\Attribute\ExceptionHandler;
use GustavPHP\Gustav\Controller\{Response, ResponseFormat};

#[ExceptionHandler]
final readonly class OrderNotFoundHandler
{
    public function __invoke(OrderNotFound $exception): Response
    {
        return new Response(
            status: 404,
            body: [
                'error' => [
                    'status' => 404,
                    'message' => 'Order not found',
                    'orderId' => $exception->orderId,
                ],
            ],
            format: ResponseFormat::Json,
        );
    }
}
```

## Handler contract

A handler must:

- be an instantiable class with exactly one `#[ExceptionHandler]` attribute;
- declare a public, non-static `__invoke()` method;
- accept exactly one ordinary, non-nullable parameter typed as an exception
  class or `Throwable`;
- return a non-null `Controller\Response`, PSR-7 `ResponseInterface`, or
  `View`.

Gustav rejects invalid signatures during startup. Parameters cannot use scalar,
union, nullable, variadic, by-reference, or intermediate interface types;
`Throwable` itself is the only supported interface target. Returns cannot be
arrays, DTOs, scalars, unions, or nullable values.

Controller routes can infer a `200` JSON response from an array or DTO, but an
exception response needs an intentional status. Requiring an explicit response
object keeps the status, headers, format, and public error body in one place.

All three response forms use their normal behavior:

```php
namespace App\ExceptionHandlers;

use App\Exceptions\{CheckoutUnavailable, MaintenanceWindow};
use GustavPHP\Gustav\Attribute\ExceptionHandler;
use GustavPHP\Gustav\View;
use Nyholm\Psr7\Response as Psr7Response;
use Psr\Http\Message\ResponseInterface;

#[ExceptionHandler]
final readonly class MaintenanceWindowHandler
{
    public function __invoke(MaintenanceWindow $exception): ResponseInterface
    {
        return new Psr7Response(503, ['Retry-After' => '60']);
    }
}

#[ExceptionHandler]
final readonly class CheckoutUnavailableHandler
{
    public function __invoke(CheckoutUnavailable $exception): View
    {
        return new View('checkout-unavailable', status: 503);
    }
}
```

`View` responses use the configured application renderer. See
[Responses](./responses.md) and [Views](./views.md) for the response
APIs.

## Dependencies

Handlers support constructor injection. The following handler adds the current
request ID to its response:

```php
use App\Exceptions\RateLimitExceeded;
use GustavPHP\Gustav\Attribute\ExceptionHandler;
use GustavPHP\Gustav\Controller\Response;
use GustavPHP\Gustav\Http\RequestId;

#[ExceptionHandler]
final readonly class RateLimitExceededHandler
{
    public function __construct(private RequestId $requestId)
    {
    }

    public function __invoke(RateLimitExceeded $exception): Response
    {
        return new Response(
            status: 429,
            headers: [
                'Retry-After' => (string) $exception->retryAfter,
                'X-Request-ID' => (string) $this->requestId,
            ],
        );
    }
}
```

## Custom namespaces

Add handler namespaces from shared packages or modules to the application
configuration:

```php
use GustavPHP\Gustav\Configuration;

return Configuration::forProject(
    namespace: 'App',
    root: dirname(__DIR__),
    exceptionHandlerNamespaces: [
        'Module\Billing\ExceptionHandlers',
    ],
);
```

## Matching order

Gustav chooses one handler in this order:

1. The exact thrown exception class
2. The nearest registered parent exception class
3. An optional `Throwable` fallback

This allows one broad domain handler with more specific overrides. Two handlers
for the same exception type are ambiguous and stop startup with an error naming
both classes.

A `Throwable` handler is a last-resort application fallback. Without one,
unmatched exceptions use Gustav's regular production-safe `500` response.

## Built-in request errors

`HttpException` and its subclasses always bypass application handlers, even
when a `Throwable` fallback exists. Their statuses, headers, and structured
request-input responses remain unchanged:

- malformed JSON remains `400`;
- unsupported body media types remain `415`;
- type and validation violations remain `422`;
- authentication, authorization, CSRF, routing, and explicit
  `HttpException` responses keep their existing behavior.

A handler targeting `HttpException` or one of its subclasses is rejected at
startup. Use `HttpException` directly for protocol-level failures; use an
application handler when a domain exception should stay independent of HTTP.

## Middleware, logging, and failure safety

Domain exceptions from controllers and their middleware are mapped before the
response unwinds through application-wide middleware. That middleware can
inspect or amend the mapped response just like a successful response. See
[Middleware](./middleware.md#mapped-error-responses).

The mapped response status controls automatic reporting:

- mapped `4xx` responses are not logged automatically;
- mapped `5xx` responses report the original domain exception exactly once.

The body returned by an application handler is intentionally public. Keep it
safe for clients and do not include credentials, internal exception messages,
queries, or stack details.

If handler construction, invocation, view rendering, or response conversion
fails, Gustav does not run another handler. It reports the new failure and
returns the regular production-safe `500` response.

## Testing handlers

Test handlers through `Application::handle()` with a PSR-7 request:

```php
use GustavPHP\Gustav\Application;
use Nyholm\Psr7\ServerRequest;

$application = new Application($configuration);
$response = $application->handle(new ServerRequest('GET', '/orders/missing'));

expect($response->getStatusCode())->toBe(404)
    ->and(json_decode((string) $response->getBody(), true))->toMatchArray([
        'error' => ['message' => 'Order not found'],
    ]);
```

Startup tests should also construct the application when they need to verify
duplicate targets or invalid signatures.
