Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 218 additions & 66 deletions docs/book/v7/core-features/exceptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

## Summary

Dotkernel API expresses error conditions through a small set of problem-specific exceptions — `BadRequestException`, `ConflictException`, `ExpiredException`, `ForbiddenException`, `MethodNotAllowedException`, `NotFoundException` and `UnauthorizedException` — each mapped to an HTTP status code.
The page lists when to throw each one and walks through adding a custom exception with its own status code.
Dotkernel API expresses error conditions through ten problem-specific exceptions under `Api\App\Exception`, each carrying the HTTP status code it maps to.
All of them implement `Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface`, are built through a static `::create()` factory rather than with `new`, and are rendered by `ProblemDetailsMiddleware` as an RFC 9457 problem details document.
This page lists when to throw each one, shows the response it produces, and walks through adding a custom exception with its own status code.

## What are exceptions?

Expand All @@ -13,66 +14,192 @@ They provide a way to manage errors in a structured and controlled manner, separ
## How we use exceptions

When it comes to handling exceptions, **Dotkernel API** relies on the usage of easy-to-understand, problem-specific exceptions.
Below we will list the available custom exceptions.
They all live in `src/App/src/Exception/` and share a single shape, shown here for `BadRequestException`:

```php
<?php

declare(strict_types=1);

namespace Api\App\Exception;

use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;

class BadRequestException extends Exception implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;

/**
* @param non-empty-string $detail
* @param array<string, mixed> $additional
*/
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
{
$exception = new self();

$exception->type = $type;
$exception->detail = $detail;
$exception->status = StatusCodeInterface::STATUS_BAD_REQUEST;
$exception->title = $title;
$exception->additional = $additional;

return $exception;
}
}
```

Two things follow from that shape, and both matter when you write code that throws:

- **The status code belongs to the class, not to the throw site.** `create()` sets `status` itself, so choosing `BadRequestException` *is* choosing `400`. There is no way to throw one of these with a different status, and nothing in the application catches an exception in order to change its status.
- **Always construct with `::create()`.** The properties the interface exposes are declared by `CommonProblemDetailsExceptionTrait` and are not constructor arguments, so `new BadRequestException('some message')` compiles but produces an exception with no status, no detail and no title.

`create()` takes the same four arguments in every one of the ten classes:

| Argument | Becomes | When omitted |
| --- | --- | --- |
| `$detail` | `detail` — what went wrong on this specific request | required |
| `$type` | `type` — a URI identifying the class of problem | derived from the status code |
| `$title` | `title` — a short summary of the problem type | the HTTP reason phrase for the status |
| `$additional` | extra top-level members, such as field-level validation errors | nothing is added |

A typical throw supplies the detail and, where there is more to say, additional members:

```php
throw BadRequestException::create(
detail: Message::VALIDATOR_INVALID_DATA,
additional: ['errors' => $this->inputFilter->getMessages()]
);
```

### Available exceptions

| Exception | Status | Raised by |
| --- | --- | --- |
| `BadRequestException` | `400 Bad Request` | handlers, on input filter failure |
| `RuntimeException` | `400 Bad Request` | `Renderer`, `HandlerService`, `ErrorReportService`, `HandlerDelegatorFactory` |
| `UnauthorizedException` | `401 Unauthorized` | `ErrorReportPermissionMiddleware`, `ErrorReportService` |
| `SunsetException` | `401 Unauthorized` | `BaseDeprecation`, on an invalid `sunset` date |
| `ForbiddenException` | `403 Forbidden` | `ErrorReportPermissionMiddleware`, `ErrorReportService` |
| `NotFoundException` | `404 Not Found` | handlers and services, for a missing resource |
| `NotAcceptableException` | `406 Not Acceptable` | `ContentNegotiationMiddleware` |
| `ConflictException` | `409 Conflict` | `UserService`, `AdminService`, `DeprecationMiddleware`, `ResourceProviderMiddleware` |
| `ExpiredException` | `410 Gone` | the password reset handlers |
| `UnsupportedMediaTypeException` | `415 Unsupported Media Type` | `ContentNegotiationMiddleware` |

Note that `RuntimeException` and `SunsetException` do not carry the status you might expect from their names — both are listed above with the status their `create()` method actually sets.

### `BadRequestException` thrown when

* The Client tries to **create/update resource**, but the **request data is invalid/incomplete** (example: client tries to create an account, but does not send the required `identity` field)
- The client tries to **create/update a resource**, but the **request data is invalid/incomplete** (example: client tries to create an account, but does not send the required `identity` field)

### `ConflictException` thrown when
This is the exception input filter failures raise, and the one you will throw most often.

* The **resource cannot be created** because a different resource with the same identifier **already exists** (example: cannot change existing user's identity because another user with the same identity already exists)
* The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active)
### `RuntimeException` thrown when

### `ExpiredException` thrown when
- A **template cannot be found** by `Api\App\Template\Renderer`
- A **service or route middleware is missing or misconfigured**, such as remote error reporting being called while disabled

* The **resource cannot be accessed**
* because it has **expired** (example: account activation link)
* because it has been **consumed** (example: one-time password)
It extends PHP's own `\RuntimeException` rather than `\Exception`, and returns `400 Bad Request`.

### `ForbiddenException` thrown when
### `UnauthorizedException` thrown when

- The **resource cannot be accessed** because the **client is not authenticated**

### `SunsetException` thrown when

* The **resource cannot be accessed** by the authenticated client's **role** (example: client authenticated as regular user sends a `GET /admin` request)
- A `ResourceDeprecation` attribute is given a **`sunset` value that is not a valid date**

### `MethodNotAllowedException` thrown when
This is a programming error surfaced at attribute construction, not a condition a client can trigger.
See [API Evolution pattern](../tutorials/api-evolution.md).

### `ForbiddenException` thrown when

* The client tries to interact with a resource via an **invalid HTTP request method** (example: client sends a `PATCH /avatar` request)
- The **resource cannot be accessed** by the authenticated client's **role**

Note that this is not what a failed RBAC check produces — see [How it works](#how-it-works) below.

### `NotFoundException` thrown when

* The client tries to interact with a **resource that does not exist** on the server (example: client sends a `GET /resource-does-not-exist` request)
- The client tries to interact with a **resource that does not exist** on the server (example: client sends a `GET /resource-does-not-exist` request)

### `UnauthorizedException` thrown when
### `NotAcceptableException` thrown when

* The **resource cannot be accessed** because the **client is not authenticated** (example: unauthenticated client sends a `GET /admin` request)
- The request's **`Accept` header asks for a format the route does not support**
- The **response cannot be produced** in any of the formats the client accepts

### `ConflictException` thrown when

- The **resource cannot be created** because a different resource with the same identifier **already exists** (example: cannot change existing user's identity because another user with the same identity already exists)
- The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active)

### `ExpiredException` thrown when

- The **resource cannot be accessed**
- because it has **expired** (example: account activation link)
- because it has been **consumed** (example: one-time password)

### `UnsupportedMediaTypeException` thrown when

- The request's **`Content-Type` is not one the route accepts**

## How it works

During a request, if there is no uncaught exception, **Dotkernel API** will return a JSON response with the data provided by the handler that processed the request.
During a request, if there is no uncaught exception, **Dotkernel API** will return a response with the data provided by the handler that processed the request.

Otherwise, the response is built by `Mezzio\ProblemDetails\ProblemDetailsMiddleware`, which `config/pipeline.php` pipes as the outermost layer precisely so that it sees every throwable the application raises.
It takes one of two paths.

**The exception implements `ProblemDetailsExceptionInterface`.**
Its status, detail, title, type and additional members are used as they are.
All ten exceptions above take this path, so a `BadRequestException` thrown from an account creation handler produces:

```json
{
"errors": {
"identity": {
"isEmpty": "Value is required and can't be empty"
}
},
"title": "Bad Request",
"type": "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
"status": 400,
"detail": "The submitted request contains invalid data."
}
```

The `type` URI comes from the `default_types_map` in `config/autoload/problem-details.global.php`, which maps the ten statuses the application uses to the matching section of RFC 9110.
A status absent from that map falls back to `https://httpstatus.es/{status}`.

Otherwise, it will build and send a response based on the exception thrown:
**Anything else.**
`Dot\Mail\Exception\MailException`, PHP errors and any exception of your own that does not implement the interface take this path.
The response is a `500 Internal Server Error` whose `detail` reads `An unknown error occurred.` — the real message is withheld unless debug mode or `exceptionDetailsInResponse` is enabled, so in production nobody learns anything from it beyond the status.

* `BadRequestException` will return a `400 Bad Request` response
* `UnauthorizedException` will return a `401 Unauthorized` response
* `ForbiddenException` will return a `403 Forbidden` response
* `OutOfBoundsException` and `NotFoundException` will return a `404 Not Found` response
* `MethodNotAllowedException` will return a `405 Method Not Allowed` response
* `ConflictException` will return a `409 Conflict` response
* `ExpiredException` will return a `410 Gone` response
* `MailException`, `RuntimeException` and the generic `Exception` will return a `500 Internal Server Error` response
Two error responses do not come from an application exception at all:

- **`405 Method Not Allowed`** is produced by `Mezzio\Router\Middleware\MethodNotAllowedMiddleware` when a route exists but not for that method. There is no `MethodNotAllowedException` in the codebase.
- **`404 Not Found`** for an unmatched URL is produced by `ProblemDetailsNotFoundHandler` at the end of the pipeline, before a route is ever dispatched.

One error response is neither of the two, and it is worth knowing about because it is the one clients hit most:

- **A failed RBAC check** returns `403 Forbidden` from `AuthorizationMiddleware`, which builds the response itself rather than throwing `ForbiddenException`. Its body is the older `{"error": {"messages": [...]}}` envelope, not a problem details document. Clients that parse error responses need to handle both shapes. See [Authorization](authorization.md).

## How to extend

In this example we will

* Create a custom exception called `CustomException`
* Place it next to the already existing custom exceptions (you can use your preferred location)
* Return a custom HTTP status code when `CustomException` is encountered.
- Create a custom exception called `TeapotException`
- Place it next to the already existing custom exceptions (you can use your preferred location)
- Return a custom HTTP status code when `TeapotException` is encountered

Because the status travels with the exception, that is the whole job — there is no pipeline or handler change to make.

### Step 1: Create exception file
### Step 1: Create the exception

Navigate to the directory `src/App/src/Handler/Exception` and create a PHP class called `CustomException.php`.
Open `CustomException.php` and add the following content:
Navigate to the directory `src/App/src/Exception` and create a PHP class called `TeapotException.php`.
Open `TeapotException.php` and add the following content:

```php
<?php
Expand All @@ -82,54 +209,72 @@ declare(strict_types=1);
namespace Api\App\Exception;

use Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;

class CustomException extends Exception
class TeapotException extends Exception implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;

/**
* @param non-empty-string $detail
* @param array<string, mixed> $additional
*/
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
{
$exception = new self();

$exception->type = $type;
$exception->detail = $detail;
$exception->status = StatusCodeInterface::STATUS_IM_A_TEAPOT;
$exception->title = $title;
$exception->additional = $additional;

return $exception;
}
}
```

Save and close the file.

### Step 2: Use exception file
The two `use` statements from `Mezzio\ProblemDetails` are what make this exception renderable: the interface is how `ProblemDetailsMiddleware` recognises it, and the trait supplies the properties `create()` fills in.

### Step 2: Throw the exception

Open the file `src/App/src/Handler/HomeHandler.php` and at the beginning of the `get` method, place the following code:
Open the file `src/App/src/Handler/GetIndexResourceHandler.php` and replace the body of the `handle` method with the following:

```php
throw new \Api\App\Exception\CustomException('some message');
throw TeapotException::create('I refuse to brew coffee.');
```

Add the import at the top of the file:

```php
use Api\App\Exception\TeapotException;
```

Save and close the file.

### Step 3: Test for failure
### Step 3: Test

Access your API's home page URL and make sure it returns `500 Internal Server Error` HTTP status code and the following content:
Access your API's home page URL.
It returns a `418 I'm a teapot` HTTP status code and the following content:

```json
{
"error": {
"messages": [
"some message"
]
}
"title": "I'm a teapot",
"type": "https://httpstatus.es/418",
"status": 418,
"detail": "I refuse to brew coffee."
}
```

### Step 4: Prepare for success

Open the file `src/App/src/Handler/HandlerTrait.php` and locate the `handle` method.
Insert the following lines of code before the first catch statement:

```php
} catch (\Api\App\Exception\CustomException $exception) {
return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_IM_A_TEAPOT);
```

Save and close the file.

### Step 5: Test for success
The `title` was filled in from the status code's reason phrase, and the `type` fell back to `httpstatus.es` because `418` is not in `default_types_map`.
To control both, pass them to `create()` at the throw site, or give `create()` its own defaults in the class.
To give every `418` response the same type, add the status to `default_types_map` in `config/autoload/problem-details.global.php`.

Access your API's home page URL, which should return the same content.
Notice that this time it returns `418 I'm a teapot` HTTP status code.
Revert the change to `GetIndexResourceHandler` when you are done.

## FAQ

Expand All @@ -142,6 +287,7 @@ See [Injectable input filters](../extended-features/injectable-input-filters.md)
**Q: What is the difference between `UnauthorizedException` and `ForbiddenException`?**

A: `UnauthorizedException` (401) means the client is not authenticated at all; `ForbiddenException` (403) means it is authenticated but its role does not grant access.
Note that a failed RBAC check does not throw `ForbiddenException` — `AuthorizationMiddleware` builds that `403` response itself.
See [Authorization](authorization.md).

**Q: When do I use `ConflictException`?**
Expand All @@ -154,18 +300,24 @@ It returns `409 Conflict`.
A: Resources that can no longer be used because they expired, such as an activation link, or because they were already consumed, such as a one-time password.
It returns `410 Gone`.

**Q: What happens to an exception I do not handle?**
**Q: Why can I not pass a message to the constructor?**

A: Generic exceptions, along with `MailException` and `RuntimeException`, produce a `500 Internal Server Error`.
A: The `detail`, `title`, `type`, `status` and `additional` properties come from `CommonProblemDetailsExceptionTrait` and are set by `create()`, not by `__construct()`.
`new NotFoundException(Message::USER_NOT_FOUND)` leaves the exception with no status, so use `NotFoundException::create(Message::USER_NOT_FOUND)` instead.

**Q: How do I map a custom exception to a specific status code?**

A: Create the exception class, then add a `catch` block for it in the `handle` method of `HandlerTrait.php` that returns `errorResponse()` with your chosen status code.
A: Set the status inside the exception's own `create()` method, as [How to extend](#how-to-extend) shows.
No `catch` block is involved, and there is nowhere in the application that maps exception classes to status codes.

**Q: Why does my custom exception return `500 Internal Server Error`?**

**Q: Why does my custom exception return 500 before I touch `HandlerTrait`?**
A: Because it does not implement `ProblemDetailsExceptionInterface`, so `ProblemDetailsMiddleware` treats it as an unexpected failure.
Implement the interface and use the trait, as in Step 1 above.

**Q: What happens to an exception I do not handle?**

A: Because nothing catches it yet, so it falls through to the generic handler.
Adding the catch block is what changes the status code.
A: Any throwable that does not implement `ProblemDetailsExceptionInterface` — `MailException` included — produces a `500 Internal Server Error` with the detail `An unknown error occurred.`, and the original message is only shown when debug mode is enabled.

**Q: How do exceptions relate to problem details responses?**

Expand Down