---
title: "Error Handling"
description: "Error Handling"
type: "docs"
category: "doc"
tags: []
authors: [Anonymous]
date: "2026-10-08"
last_update: "2026-10-08"
time_minutes: 7
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/http/error-handling"
---

# Error Handling

Any exception thrown while a request is processed, whether by a controller, a middleware or the router itself, is caught and turned into a response that follows [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807) (Problem Details). You do not write a `try`/`catch` for that: throw, and the response is built from the exception.

```php
public function show(string $id): array
{
    $item = $this->repository->find($id)
        ?? throw new NotFoundException(['Item {id} not found.', 'id' => $id]);

    return $item->toArray();
}
```

## How an Exception Becomes a Response

1. The `RequestHandler` catches the exception.
2. `ProblemFactory` builds a `ProblemDetail` from it and from the request.
3. `ProblemHandler` renders the problem in the format the client prefers.

### The HTTP status

The status is the first of these that applies:

1. `getStatus()`, if the exception implements `HttpExceptionInterface`.
2. The exception code, if it is a valid HTTP status code.
3. `404` for a `RouteNotFoundException`.
4. `500` for anything else.

### The problem

| Field | Value |
|---|---|
| `type` | `getUriReference()` of an `HttpExceptionInterface`, otherwise `about:blank` |
| `title` | `getTitle()` of an `HttpExceptionInterface`; for any other exception, the reason phrase of the status. It is translated when there is a [translator](#translation) |
| `status` | The HTTP status code |
| `detail` | The message of the exception. If the exception is translatable, it is translated when there is a [translator](#translation) |
| `instance` | The path of the request |
| `extensions` | `timestamp`, `environment`, `debug`, `context`, `headers` and `throwable` |

The title is the one the problem is created with, also when the `type` is `about:blank`: with `about:blank` it should be the reason phrase of the status, which can be localized ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457), section 4.2.1). Without a title, the reason phrase of the status is used.

`throwable` carries the exception, with its trace, only when `kernel.debug` is on. Otherwise it is `null`, so production responses do not expose internals.

## Translation

`ProblemFactory` translates the `title` and the `detail` of the problem when it has a translator (`Symfony\Contracts\Translation\TranslatorInterface`). It receives it as an optional argument of its constructor: when the container injects one the texts are translated, and without one they are left as they are written.

- The **detail** is the message of the exception. It is translated when the exception is translatable (it implements `TranslatableInterface`, see [HTTP Exceptions](#http-exceptions)), with the catalogue of the domain the exception uses. Any other exception keeps its message.
- The **title** is its own translation id, in the `errors` domain, the same one the messages of the exceptions use. The reason phrases of the statuses (`Not Found`, `Forbidden`...) have their Spanish translation in the catalogue of the package. The title of your own `HttpExceptionInterface` needs an entry in your catalogue; a text without an entry is kept as it is.

The catalogue of the package is provided by `Derafu\Http\Translation\HttpTranslationResourceProvider`. It is registered, with the other services of the package, by `resources/config/http-services.yaml`, which you import from your `services.yaml`:

```yaml
imports:
    - { resource: '../vendor/derafu/http/resources/config/http-services.yaml' }
```

To register only the catalogue, tag the provider with `derafu_translation.resource_provider`, as [`derafu/translation`](https://www.derafu.dev/docs/core/translation) explains.

## Response Formats

The format follows the content negotiation of the request. See [Requests and Responses](requests-responses).

### JSON

An API request, or a client that asks for JSON, gets the problem as JSON with the status of the error:

```json
{
    "type": "about:blank",
    "title": "Not Found",
    "status": 404,
    "detail": "No route found for \"/api/mustFail\".",
    "instance": "/api/mustFail",
    "extensions": {
        "timestamp": "2025-02-20T22:04:25+00:00",
        "environment": "prod",
        "debug": false,
        "context": [],
        "headers": [],
        "throwable": null
    }
}
```

### HTML

For a browser, the library renders an error page. The pages are given to `ProblemHandler` as a map from the status (`404`, `500`...) to a handler, and `default` for the statuses that have none. A handler is the same kind of string as the handler of a route: the path of a template or a `Controller::action`. The map is the parameter `http.error_pages`, which you set in your `services.yaml` (with [`derafu/foundation`](https://www.derafu.dev/docs/core/foundation) it already has `templates/error.html.twig` as `default`):

```yaml
parameters:
    http.error_pages:
        # Specific page for a 404.
        404: '%kernel.project_dir%/templates/error404.html.twig'
        # The page for any other status. It can be a controller.
        default: 'App\Controller\ErrorController::show'
```

The page of the status is used first, and then `default`; if a page fails the next one is tried. When the handler returns plain content, like a rendered template, the page is sent with the status of the problem. A handler that returns its own `Response` is sent as is.

The error pages are **not routes**: a route is a resource with a URL that anyone can ask for, and an error page only makes sense as the answer to an error. `/error` is a path like any other that does not exist.

The problem is passed to the handler inside the `context` array, under the `error` key. In a Twig template it is available as `context.error`:

```twig
<h1>{{ context.error.status }}</h1>
<h2>{{ context.error.title }}</h2>
<p>{{ context.error.detail }}</p>

{% if context.error.debug %}
    <pre>{{ context.error.throwable.traceAsString }}</pre>
{% endif %}
```

The keys of the map must be a status from 400 to 599 or `default`, and a handler a text that is not empty (or a `Closure`, for a service that is built in PHP): anything else is an error of the configuration, thrown when the handler is created.

If there are no pages, or all of them fail, the problem is sent as a Markdown text instead. That is the last resort for a serious failure, not the answer to a page that is missing. With `kernel.debug` on, the text says which pages failed and why (`## Error pages that failed`).

### Markdown

A request for a URL that ends in `.md` gets the problem as Markdown text, and so does the HTML fallback above. With `kernel.debug` on it includes the exception and its trace.

## HTTP Exceptions

To control the status, title, type, context and headers of an error, implement `Derafu\Http\Contract\HttpExceptionInterface`:

| Method | Returns |
|---|---|
| `getStatus()` | The `HttpStatus` of the response |
| `getTitle()` | A short summary of the type of problem |
| `getUriReference()` | A URI that identifies the type of problem |
| `getContext()` | Extra data, sent in `extensions.context` |
| `getHeaders()` | Headers added to the response |

The interface extends `TranslatableInterface` from `derafu/translation`, so the message is written in its `[message, ...params]` form, where the parameters are interpolated into `{placeholders}`.

```php
use Derafu\Http\Contract\HttpExceptionInterface;
use Derafu\Http\Enum\HttpStatus;
use Derafu\Translation\Exception\Core\TranslatableRuntimeException;

class NotFoundException extends TranslatableRuntimeException implements HttpExceptionInterface
{
    public function getUriReference(): string
    {
        return 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404';
    }

    public function getTitle(): string
    {
        return 'Not Found';
    }

    public function getStatus(): HttpStatus
    {
        return HttpStatus::NOT_FOUND;
    }

    public function getContext(): array
    {
        return [];
    }

    public function getHeaders(): array
    {
        return [];
    }
}
```

### Provided exceptions

- `BadRequestException`: a `400`, for a request that can not be attended because of what the client sent (a field that is missing, a file that is not there). It is an `InvalidArgumentException`, so a service that validates what it receives can throw it without knowing about HTTP, and the callers that already catch the invalid argument keep working. Its message is `Bad request.` unless you give another one.
- `TooManyRequestsException`: a `429`. It accepts the headers to send as its last constructor argument. `ThrottleMiddleware` uses it to send `Retry-After` and the `X-RateLimit-*` headers. See [Middleware](middleware).
- `InvalidPathException` (of `derafu/routing`): a `400`, thrown by the router when the path of the request has no safe canonical form (a `..` segment, an escaped slash, a control character). See [Middleware](middleware#the-path-that-the-rest-of-the-pipeline-sees).
- `ResponseSerializationException`: thrown when a response can not be encoded as JSON and is not a string.
- `DispatcherException`: thrown when the handler of a route is not valid.

## Notes

- **A problem is built only when something fails.** A request that succeeds does not go through this code.
- **Headers always apply.** The headers of an `HttpExceptionInterface` are added to the response whatever the format.
- **Keep `kernel.debug` off in production.** It is what hides the exception and its trace from the response.



---
Last updated on 08/10/2026

