xchert / exception
A Symfony bundle for structured exception handling, error mapping, and JSON-serializable error responses.
Requires
- php: ^8.4
- symfony/config: ^8.1
- symfony/dependency-injection: ^8.1
- symfony/http-kernel: ^8.1
- symfony/uid: ^8.1
- symfony/validator: ^8.1
- xchert/util: ^1.1
Requires (Dev)
- phpunit/phpunit: ^13.4
- symfony/framework-bundle: ^8.1
- symfony/yaml: ^8.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-17 19:36:58 UTC
README
A Symfony bundle for structured exception handling. It maps any exception to error objects and turns them into JSON responses following RFC 9457 (Problem Details for HTTP APIs).
Requirements
- PHP 8.4 or higher
- Symfony 8.1 or higher
Installation
1. Install via Composer
composer require xchert/exception
2. Register the Bundle
If you are not using Symfony Flex, register the bundle manually in config/bundles.php:
// config/bundles.php return [ // ... Xchert\Exception\ExceptionBundle::class => ['all' => true], ];
3. Configuration (optional)
RFC 9457 identifies a problem type by a URI in the type member. If an absolute base URI is configured, the bundle
appends the error code to it. Without configuration, type is always about:blank.
# config/packages/exception.yaml exception: problem_type_base_uri: 'https://example.com/problems/'
An exception with the error code USER_NOT_FOUND then gets the type https://example.com/problems/USER_NOT_FOUND.
Errors without a known error code (UNKNOWN_ERROR) still use about:blank. Ideally, the URI resolves to a
human-readable description of the problem.
The base URI must be absolute (e.g. https://… or urn:…) and should end with a separator such as /, because the
error code is appended directly.
Usage
1. Custom Exceptions
Custom exceptions extend ErrorException. They define the error code and optionally the HTTP status code (default:
500). Placeholders like {{ name }} in the message are replaced with the given parameters.
namespace App\Exception; use Symfony\Component\HttpFoundation\Response; use Xchert\Exception\ErrorException; class UserNotFoundException extends ErrorException { public function __construct(string $userId, ?\Throwable $previous = null) { parent::__construct( message: 'User with ID "{{ userId }}" was not found.', title: 'User not found', parameters: ['userId' => $userId], previous: $previous, ); } public function getErrorCode(): string { return 'USER_NOT_FOUND'; } public function getStatusCode(): int { return Response::HTTP_NOT_FOUND; } }
The constructor of ErrorException takes:
| Parameter | Description |
|---|---|
$message |
Message, optionally with placeholders {{ name }} |
$title |
Short, human-readable summary of the problem |
$parameters |
Values for the placeholders, also included in the response |
$headers |
Additional HTTP headers for the response |
$previous |
The exception that caused this one |
Every ErrorException gets a unique ID (UUID v7) and a timestamp when it is created. The ID appears in the response as
instance (urn:uuid:<id>) and helps to find the error in your logs, for example.
2. The ErrorFactory
The ErrorFactory is available as a service and turns an exception into error objects or a response:
| Method | Returns |
|---|---|
createResponse($exception, $debug) |
JsonResponse with application/problem+json, status code and headers |
createError($exception, $debug) |
A single Error object for the response |
createErrors($exception, $debug) |
Flat list of all Error objects created by the mappers |
getStatusCode($exception) |
HTTP status code |
getErrorCode($exception) |
Error code |
getHeaders($exception) |
HTTP headers |
createError() returns the error as is if createErrors() yields exactly one. Otherwise, it creates an error for the
thrown exception that contains the list in its errors member.
With $debug = true, the stack trace is included under meta.trace. Enable this in development only.
In a Controller
use Xchert\Exception\ErrorFactory\ErrorFactory; public function show(string $id, ErrorFactory $errorFactory): Response { try { // ... } catch (\Throwable $e) { return $errorFactory->createResponse($e, $this->getParameter('kernel.debug')); } }
For All Exceptions of the Application
The bundle does not register an exception listener itself. To return every exception as problem details, add your own listener:
namespace App\EventListener; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Xchert\Exception\ErrorFactory\ErrorFactory; #[AsEventListener] final readonly class ProblemDetailsListener { public function __construct( private ErrorFactory $errorFactory, #[Autowire('%kernel.debug%')] private bool $debug, ) {} public function __invoke(ExceptionEvent $event): void { $event->setResponse($this->errorFactory->createResponse($event->getThrowable(), $this->debug)); } }
3. Response Format
The members of an error are filled as follows:
| Member | Content |
|---|---|
instance |
urn:uuid:<id>, unique ID of the error |
type |
Base URI + error code, or about:blank |
title |
Title of the exception; the HTTP status text for HTTP exceptions |
detail |
Message of the exception |
status |
HTTP status code |
code |
Error code (extension) |
meta.occurredAt |
Time the error occurred (extension) |
meta.trace |
Stack trace, debug mode only (extension) |
additional |
Additional data, e.g. parameters or pointer (extension) |
errors |
Nested errors (extension) |
Empty members (additional, errors, meta.trace) are omitted.
Example for the UserNotFoundException shown above:
{
"instance": "urn:uuid:01a14b4e-a3ec-7198-92a5-2becc4710e9b",
"type": "https://example.com/problems/USER_NOT_FOUND",
"title": "User not found",
"detail": "User with ID \"42\" was not found.",
"status": 404,
"code": "USER_NOT_FOUND",
"meta": {
"occurredAt": "2026-10-17T19:20:07.404+00:00"
},
"additional": {
"parameters": {
"userId": "42"
}
}
}
Built-in Mappers
Mappers translate an exception into Error objects. For each exception, the first supporting mapper with the highest
priority is used. Exceptions without a supporting mapper become an error with the code UNKNOWN_ERROR and the status
500.
| Mapper | Priority | Handles |
|---|---|---|
StackExceptionMapper |
1100 | StackException |
ConstraintViolationExceptionMapper |
1100 | ConstraintViolationException |
ErrorExceptionMapper |
1000 | ErrorExceptionInterface |
HttpExceptionMapper |
0 | Symfony's HttpException |
For HTTP exceptions and ErrorExceptions, a previous exception is mapped as well and nested under errors.
Validation Errors
Throw Symfony validation errors with the ConstraintViolationException:
use Xchert\Exception\Validation\ConstraintViolationException; $violations = $validator->validate($data); if (\count($violations) > 0) { throw new ConstraintViolationException($violations, $data); }
Each violation becomes its own error with the status 400. The error code is the error name of the constraint (e.g.
IS_BLANK_ERROR), or CONSTRAINT_VIOLATION if none can be determined. The property path is included as a
JSON Pointer (RFC 6901) under additional.pointer, e.g. address.street
becomes /address/street.
{
"instance": "urn:uuid:01a14b4e-a3eb-7a72-9bdc-f4558c7a08a9",
"type": "https://example.com/problems/CONSTRAINT_VIOLATION",
"title": "Constraint validation failed.",
"detail": "Constraint validation failed. Caught 2 errors:\n • [username]: This value should not be blank.\n • [email]: This value is not a valid email address.\n",
"status": 400,
"code": "CONSTRAINT_VIOLATION",
"meta": {
"occurredAt": "2026-10-17T19:20:07.403+00:00"
},
"additional": {
"parameters": {
"errorCount": 2,
"violationsMessage": " • [username]: This value should not be blank.\n • [email]: This value is not a valid email address.\n"
}
},
"errors": [
{
"instance": "urn:uuid:01a14b4e-a3eb-7ba6-9bdc-f4558d5176a4",
"type": "https://example.com/problems/IS_BLANK_ERROR",
"title": "IS_BLANK_ERROR",
"detail": "This value should not be blank.",
"status": 400,
"code": "IS_BLANK_ERROR",
"meta": {
"occurredAt": "2026-10-17T19:20:07.403+00:00"
},
"additional": {
"parameters": {
"{{ value }}": "\"\""
},
"pointer": "/username"
}
},
{
"instance": "urn:uuid:01a14b4e-a3eb-7c0a-9bdc-f4558dc8e612",
"type": "https://example.com/problems/INVALID_FORMAT_ERROR",
"title": "INVALID_FORMAT_ERROR",
"detail": "This value is not a valid email address.",
"status": 400,
"code": "INVALID_FORMAT_ERROR",
"meta": {
"occurredAt": "2026-10-17T19:20:07.403+00:00"
},
"additional": {
"parameters": {
"{{ value }}": "\"foo\""
},
"pointer": "/email"
}
}
]
}
Collecting Multiple Errors
The ExceptionStack collects multiple exceptions and throws them together as a StackException. Each collected
exception is mapped by its supporting mapper and included under errors.
use Symfony\Component\HttpFoundation\Response; use Xchert\Exception\ExceptionStack; $stack = new ExceptionStack( errorCode: 'IMPORT_FAILED', title: 'Import failed', statusCode: Response::HTTP_UNPROCESSABLE_ENTITY, ); foreach ($rows as $row) { try { $importer->import($row); } catch (\Throwable $e) { $stack->add($e); } } $stack->throw(); // Throws only if errors were collected
Without a custom message, the message is composed of the messages of the collected exceptions. The title defaults to
Multiple errors occurred., the status code to 500.
Custom Mappers
Write a custom mapper for exceptions from third-party libraries or your own domain. It implements the
ErrorMapperInterface:
namespace App\ErrorMapper; use Symfony\Component\HttpFoundation\Response; use Xchert\Exception\Attribute\AsErrorMapper; use Xchert\Exception\Error; use Xchert\Exception\ErrorFactory\ErrorFactory; use Xchert\Exception\ErrorFactory\ErrorMapperInterface; use Xchert\Exception\Util; #[AsErrorMapper(id: 'domain_exception', priority: 500)] class DomainExceptionMapper implements ErrorMapperInterface { private const string CODE = 'DOMAIN_ERROR'; public function supports(\Throwable $exception): bool { return $exception instanceof \DomainException; } public function getErrors(\Throwable $exception, bool $debug, ErrorFactory $factory): array { return [$this->createError($exception, $debug, [], $factory)]; } public function createError(\Throwable $exception, bool $debug, array $errors, ErrorFactory $factory): Error { return new Error( instance: Util::createInstance(), type: $factory->createType(self::CODE), title: $this->getTitle($exception), detail: $exception->getMessage(), status: $this->getStatusCode($exception), code: self::CODE, occurredAt: new \DateTimeImmutable(), trace: $debug ? $exception->getTrace() : null, errors: $errors, ); } public function getStatusCode(\Throwable $exception): int { return Response::HTTP_UNPROCESSABLE_ENTITY; } public function getHeaders(\Throwable $exception): array { return []; } public function getErrorCode(\Throwable $exception): string { return self::CODE; } public function getTitle(\Throwable $exception): string { return 'Domain rule violated'; } }
| Method | Purpose |
|---|---|
supports() |
Checks whether the mapper handles the exception |
getErrors() |
Returns the errors for the exception. Multiple entries are wrapped in an error created by createError() |
createError() |
Creates the error for the exception, with $errors as nested errors |
getStatusCode(), getHeaders(), getErrorCode(), getTitle() |
Return the respective values for the response |
If the behavior is similar, a mapper can also extend one of the built-in mappers, e.g. the HttpExceptionMapper.
Registering a Mapper
With autoconfigure enabled (the default in Symfony applications), the #[AsErrorMapper] attribute is all you need. The
id must be unique; a mapper with the same id replaces one that is already registered. The priority defines the
order in which the mappers are checked. To override a built-in mapper, choose a higher priority.
Without autoconfigure, tag the mapper manually with exception.error_mapper:
# config/services.yaml services: App\ErrorMapper\DomainExceptionMapper: tags: - { name: 'exception.error_mapper', id: 'domain_exception', priority: 500 }