Skip to content

Commit a86d4a6

Browse files
authored
Merge pull request #170 from dotkernel/exceptions-problem-details-rewrite
Rewrite v7 exceptions page for problem details
2 parents d0ea4fd + ecabd77 commit a86d4a6

1 file changed

Lines changed: 218 additions & 66 deletions

File tree

‎docs/book/v7/core-features/exceptions.md‎

Lines changed: 218 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
## Summary
44

5-
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.
6-
The page lists when to throw each one and walks through adding a custom exception with its own status code.
5+
Dotkernel API expresses error conditions through ten problem-specific exceptions under `Api\App\Exception`, each carrying the HTTP status code it maps to.
6+
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.
7+
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.
78

89
## What are exceptions?
910

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

1516
When it comes to handling exceptions, **Dotkernel API** relies on the usage of easy-to-understand, problem-specific exceptions.
16-
Below we will list the available custom exceptions.
17+
They all live in `src/App/src/Exception/` and share a single shape, shown here for `BadRequestException`:
18+
19+
```php
20+
<?php
21+
22+
declare(strict_types=1);
23+
24+
namespace Api\App\Exception;
25+
26+
use Exception;
27+
use Fig\Http\Message\StatusCodeInterface;
28+
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
29+
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
30+
31+
class BadRequestException extends Exception implements ProblemDetailsExceptionInterface
32+
{
33+
use CommonProblemDetailsExceptionTrait;
34+
35+
/**
36+
* @param non-empty-string $detail
37+
* @param array<string, mixed> $additional
38+
*/
39+
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
40+
{
41+
$exception = new self();
42+
43+
$exception->type = $type;
44+
$exception->detail = $detail;
45+
$exception->status = StatusCodeInterface::STATUS_BAD_REQUEST;
46+
$exception->title = $title;
47+
$exception->additional = $additional;
48+
49+
return $exception;
50+
}
51+
}
52+
```
53+
54+
Two things follow from that shape, and both matter when you write code that throws:
55+
56+
- **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.
57+
- **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.
58+
59+
`create()` takes the same four arguments in every one of the ten classes:
60+
61+
| Argument | Becomes | When omitted |
62+
| --- | --- | --- |
63+
| `$detail` | `detail` — what went wrong on this specific request | required |
64+
| `$type` | `type` — a URI identifying the class of problem | derived from the status code |
65+
| `$title` | `title` — a short summary of the problem type | the HTTP reason phrase for the status |
66+
| `$additional` | extra top-level members, such as field-level validation errors | nothing is added |
67+
68+
A typical throw supplies the detail and, where there is more to say, additional members:
69+
70+
```php
71+
throw BadRequestException::create(
72+
detail: Message::VALIDATOR_INVALID_DATA,
73+
additional: ['errors' => $this->inputFilter->getMessages()]
74+
);
75+
```
76+
77+
### Available exceptions
78+
79+
| Exception | Status | Raised by |
80+
| --- | --- | --- |
81+
| `BadRequestException` | `400 Bad Request` | handlers, on input filter failure |
82+
| `RuntimeException` | `400 Bad Request` | `Renderer`, `HandlerService`, `ErrorReportService`, `HandlerDelegatorFactory` |
83+
| `UnauthorizedException` | `401 Unauthorized` | `ErrorReportPermissionMiddleware`, `ErrorReportService` |
84+
| `SunsetException` | `401 Unauthorized` | `BaseDeprecation`, on an invalid `sunset` date |
85+
| `ForbiddenException` | `403 Forbidden` | `ErrorReportPermissionMiddleware`, `ErrorReportService` |
86+
| `NotFoundException` | `404 Not Found` | handlers and services, for a missing resource |
87+
| `NotAcceptableException` | `406 Not Acceptable` | `ContentNegotiationMiddleware` |
88+
| `ConflictException` | `409 Conflict` | `UserService`, `AdminService`, `DeprecationMiddleware`, `ResourceProviderMiddleware` |
89+
| `ExpiredException` | `410 Gone` | the password reset handlers |
90+
| `UnsupportedMediaTypeException` | `415 Unsupported Media Type` | `ContentNegotiationMiddleware` |
91+
92+
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.
1793

1894
### `BadRequestException` thrown when
1995

20-
* 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)
96+
- 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)
2197

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

24-
* 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)
25-
* The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active)
100+
### `RuntimeException` thrown when
26101

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

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

33-
### `ForbiddenException` thrown when
107+
### `UnauthorizedException` thrown when
108+
109+
- The **resource cannot be accessed** because the **client is not authenticated**
110+
111+
### `SunsetException` thrown when
34112

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

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

39-
* The client tries to interact with a resource via an **invalid HTTP request method** (example: client sends a `PATCH /avatar` request)
120+
- The **resource cannot be accessed** by the authenticated client's **role**
121+
122+
Note that this is not what a failed RBAC check produces — see [How it works](#how-it-works) below.
40123

41124
### `NotFoundException` thrown when
42125

43-
* 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)
126+
- 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)
44127

45-
### `UnauthorizedException` thrown when
128+
### `NotAcceptableException` thrown when
46129

47-
* The **resource cannot be accessed** because the **client is not authenticated** (example: unauthenticated client sends a `GET /admin` request)
130+
- The request's **`Accept` header asks for a format the route does not support**
131+
- The **response cannot be produced** in any of the formats the client accepts
132+
133+
### `ConflictException` thrown when
134+
135+
- 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)
136+
- The **resource cannot change its state** because it is **already in the specified state** (example: user cannot be activated because it is already active)
137+
138+
### `ExpiredException` thrown when
139+
140+
- The **resource cannot be accessed**
141+
- because it has **expired** (example: account activation link)
142+
- because it has been **consumed** (example: one-time password)
143+
144+
### `UnsupportedMediaTypeException` thrown when
145+
146+
- The request's **`Content-Type` is not one the route accepts**
48147

49148
## How it works
50149

51-
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.
150+
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.
151+
152+
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.
153+
It takes one of two paths.
154+
155+
**The exception implements `ProblemDetailsExceptionInterface`.**
156+
Its status, detail, title, type and additional members are used as they are.
157+
All ten exceptions above take this path, so a `BadRequestException` thrown from an account creation handler produces:
158+
159+
```json
160+
{
161+
"errors": {
162+
"identity": {
163+
"isEmpty": "Value is required and can't be empty"
164+
}
165+
},
166+
"title": "Bad Request",
167+
"type": "https://datatracker.ietf.org/doc/html/rfc9110#name-400-bad-request",
168+
"status": 400,
169+
"detail": "The submitted request contains invalid data."
170+
}
171+
```
172+
173+
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.
174+
A status absent from that map falls back to `https://httpstatus.es/{status}`.
52175

53-
Otherwise, it will build and send a response based on the exception thrown:
176+
**Anything else.**
177+
`Dot\Mail\Exception\MailException`, PHP errors and any exception of your own that does not implement the interface take this path.
178+
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.
54179

55-
* `BadRequestException` will return a `400 Bad Request` response
56-
* `UnauthorizedException` will return a `401 Unauthorized` response
57-
* `ForbiddenException` will return a `403 Forbidden` response
58-
* `OutOfBoundsException` and `NotFoundException` will return a `404 Not Found` response
59-
* `MethodNotAllowedException` will return a `405 Method Not Allowed` response
60-
* `ConflictException` will return a `409 Conflict` response
61-
* `ExpiredException` will return a `410 Gone` response
62-
* `MailException`, `RuntimeException` and the generic `Exception` will return a `500 Internal Server Error` response
180+
Two error responses do not come from an application exception at all:
181+
182+
- **`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.
183+
- **`404 Not Found`** for an unmatched URL is produced by `ProblemDetailsNotFoundHandler` at the end of the pipeline, before a route is ever dispatched.
184+
185+
One error response is neither of the two, and it is worth knowing about because it is the one clients hit most:
186+
187+
- **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).
63188

64189
## How to extend
65190

66191
In this example we will
67192

68-
* Create a custom exception called `CustomException`
69-
* Place it next to the already existing custom exceptions (you can use your preferred location)
70-
* Return a custom HTTP status code when `CustomException` is encountered.
193+
- Create a custom exception called `TeapotException`
194+
- Place it next to the already existing custom exceptions (you can use your preferred location)
195+
- Return a custom HTTP status code when `TeapotException` is encountered
196+
197+
Because the status travels with the exception, that is the whole job — there is no pipeline or handler change to make.
71198

72-
### Step 1: Create exception file
199+
### Step 1: Create the exception
73200

74-
Navigate to the directory `src/App/src/Handler/Exception` and create a PHP class called `CustomException.php`.
75-
Open `CustomException.php` and add the following content:
201+
Navigate to the directory `src/App/src/Exception` and create a PHP class called `TeapotException.php`.
202+
Open `TeapotException.php` and add the following content:
76203

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

84211
use Exception;
212+
use Fig\Http\Message\StatusCodeInterface;
213+
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
214+
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
85215

86-
class CustomException extends Exception
216+
class TeapotException extends Exception implements ProblemDetailsExceptionInterface
87217
{
218+
use CommonProblemDetailsExceptionTrait;
219+
220+
/**
221+
* @param non-empty-string $detail
222+
* @param array<string, mixed> $additional
223+
*/
224+
public static function create(string $detail, string $type = '', string $title = '', array $additional = []): self
225+
{
226+
$exception = new self();
227+
228+
$exception->type = $type;
229+
$exception->detail = $detail;
230+
$exception->status = StatusCodeInterface::STATUS_IM_A_TEAPOT;
231+
$exception->title = $title;
232+
$exception->additional = $additional;
233+
234+
return $exception;
235+
}
88236
}
89237
```
90238

91239
Save and close the file.
92240

93-
### Step 2: Use exception file
241+
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.
242+
243+
### Step 2: Throw the exception
94244

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

97247
```php
98-
throw new \Api\App\Exception\CustomException('some message');
248+
throw TeapotException::create('I refuse to brew coffee.');
249+
```
250+
251+
Add the import at the top of the file:
252+
253+
```php
254+
use Api\App\Exception\TeapotException;
99255
```
100256

101257
Save and close the file.
102258

103-
### Step 3: Test for failure
259+
### Step 3: Test
104260

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

107264
```json
108265
{
109-
"error": {
110-
"messages": [
111-
"some message"
112-
]
113-
}
266+
"title": "I'm a teapot",
267+
"type": "https://httpstatus.es/418",
268+
"status": 418,
269+
"detail": "I refuse to brew coffee."
114270
}
115271
```
116272

117-
### Step 4: Prepare for success
118-
119-
Open the file `src/App/src/Handler/HandlerTrait.php` and locate the `handle` method.
120-
Insert the following lines of code before the first catch statement:
121-
122-
```php
123-
} catch (\Api\App\Exception\CustomException $exception) {
124-
return $this->errorResponse($exception->getMessage(), StatusCodeInterface::STATUS_IM_A_TEAPOT);
125-
```
126-
127-
Save and close the file.
128-
129-
### Step 5: Test for success
273+
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`.
274+
To control both, pass them to `create()` at the throw site, or give `create()` its own defaults in the class.
275+
To give every `418` response the same type, add the status to `default_types_map` in `config/autoload/problem-details.global.php`.
130276

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

134279
## FAQ
135280

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

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

147293
**Q: When do I use `ConflictException`?**
@@ -154,18 +300,24 @@ It returns `409 Conflict`.
154300
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.
155301
It returns `410 Gone`.
156302

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

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

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

163-
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.
310+
A: Set the status inside the exception's own `create()` method, as [How to extend](#how-to-extend) shows.
311+
No `catch` block is involved, and there is nowhere in the application that maps exception classes to status codes.
312+
313+
**Q: Why does my custom exception return `500 Internal Server Error`?**
164314

165-
**Q: Why does my custom exception return 500 before I touch `HandlerTrait`?**
315+
A: Because it does not implement `ProblemDetailsExceptionInterface`, so `ProblemDetailsMiddleware` treats it as an unexpected failure.
316+
Implement the interface and use the trait, as in Step 1 above.
317+
318+
**Q: What happens to an exception I do not handle?**
166319

167-
A: Because nothing catches it yet, so it falls through to the generic handler.
168-
Adding the catch block is what changes the status code.
320+
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.
169321

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

0 commit comments

Comments
 (0)