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.
Exceptions are a powerful mechanism for handling errors and other exceptional conditions that may occur during the execution of a script. They provide a way to manage errors in a structured and controlled manner, separating error-handling code from regular code.
When it comes to handling exceptions, Dotkernel API relies on the usage of easy-to-understand, problem-specific exceptions.
They all live in src/App/src/Exception/ and share a single shape, shown here for BadRequestException:
<?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:
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.::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:
throw BadRequestException::create(
detail: Message::VALIDATOR_INVALID_DATA,
additional: ['errors' => $this->inputFilter->getMessages()]
);
| 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 whenidentity field)This is the exception input filter failures raise, and the one you will throw most often.
RuntimeException thrown whenApi\App\Template\RendererIt extends PHP's own \RuntimeException rather than \Exception, and returns 400 Bad Request.
UnauthorizedException thrown whenSunsetException thrown whenResourceDeprecation attribute is given a sunset value that is not a valid dateThis is a programming error surfaced at attribute construction, not a condition a client can trigger. See API Evolution pattern.
ForbiddenException thrown whenNote that this is not what a failed RBAC check produces — see How it works below.
NotFoundException thrown whenGET /resource-does-not-exist request)NotAcceptableException thrown whenAccept header asks for a format the route does not supportConflictException thrown whenExpiredException thrown whenUnsupportedMediaTypeException thrown whenContent-Type is not one the route acceptsDuring 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:
{
"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}.
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.
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:
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.In this example we will
TeapotExceptionTeapotException is encounteredBecause the status travels with the exception, that is the whole job — there is no pipeline or handler change to make.
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
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 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.
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.
Open the file src/App/src/Handler/GetIndexResourceHandler.php and replace the body of the handle method with the following:
throw TeapotException::create('I refuse to brew coffee.');
Add the import at the top of the file:
use Api\App\Exception\TeapotException;
Save and close the file.
Access your API's home page URL.
It returns a 418 I'm a teapot HTTP status code and the following content:
{
"title": "I'm a teapot",
"type": "https://httpstatus.es/418",
"status": 418,
"detail": "I refuse to brew coffee."
}
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.
Revert the change to GetIndexResourceHandler when you are done.
Q: Which exception should I throw for invalid request data?
A: BadRequestException, which produces a 400 Bad Request.
It is what input filter failures raise.
See Injectable input filters.
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.
Q: When do I use ConflictException?
A: When a resource cannot be created because one with the same identifier exists, or cannot change state because it is already in that state.
It returns 409 Conflict.
Q: What does ExpiredException cover?
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: Why can I not pass a message to the constructor?
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: Set the status inside the exception's own create() method, as 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?
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: 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?
A: The exceptions carry the title, type, status, detail and any additional fields that the problem details middleware renders. See Problem details.