You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit a86d4a6
Browse filesBrowse the repository at this point in the historyBrowse files
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.
7
8
8
9
## What are exceptions?
9
10
@@ -13,66 +14,192 @@ They provide a way to manage errors in a structured and controlled manner, separ
13
14
## How we use exceptions
14
15
15
16
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
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:
|`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.
17
93
18
94
### `BadRequestException` thrown when
19
95
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)
21
97
22
-
### `ConflictException` thrown when
98
+
This is the exception input filter failures raise, and the one you will throw most often.
23
99
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
26
101
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
28
104
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`.
32
106
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
34
112
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**
36
114
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
38
119
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.
40
123
41
124
### `NotFoundException` thrown when
42
125
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)
44
127
45
-
### `UnauthorizedException` thrown when
128
+
### `NotAcceptableException` thrown when
46
129
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**
48
147
49
148
## How it works
50
149
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.
"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}`.
52
175
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.
54
179
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).
63
188
64
189
## How to extend
65
190
66
191
In this example we will
67
192
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.
71
198
72
-
### Step 1: Create exception file
199
+
### Step 1: Create the exception
73
200
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:
76
203
77
204
```php
78
205
<?php
@@ -82,54 +209,72 @@ declare(strict_types=1);
82
209
namespace Api\App\Exception;
83
210
84
211
use Exception;
212
+
use Fig\Http\Message\StatusCodeInterface;
213
+
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
214
+
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
85
215
86
-
class CustomException extends Exception
216
+
class TeapotException extends Exception implements ProblemDetailsExceptionInterface
87
217
{
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
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
94
244
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:
96
246
97
247
```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;
99
255
```
100
256
101
257
Save and close the file.
102
258
103
-
### Step 3: Test for failure
259
+
### Step 3: Test
104
260
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:
106
263
107
264
```json
108
265
{
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."
114
270
}
115
271
```
116
272
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:
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`.
130
276
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.
133
278
134
279
## FAQ
135
280
@@ -142,6 +287,7 @@ See [Injectable input filters](../extended-features/injectable-input-filters.md)
142
287
**Q: What is the difference between `UnauthorizedException` and `ForbiddenException`?**
143
288
144
289
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.
145
291
See [Authorization](authorization.md).
146
292
147
293
**Q: When do I use `ConflictException`?**
@@ -154,18 +300,24 @@ It returns `409 Conflict`.
154
300
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.
155
301
It returns `410 Gone`.
156
302
157
-
**Q: What happens to an exception I do not handle?**
303
+
**Q: Why can I not pass a message to the constructor?**
158
304
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.
160
307
161
308
**Q: How do I map a custom exception to a specific status code?**
162
309
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`?**
164
314
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?**
166
319
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.
169
321
170
322
**Q: How do exceptions relate to problem details responses?**
0 commit comments