mnavarrocarter/problem-details-bundle
A Symfony bundle for RFC 7807 problem details that turns exceptions and validation errors into consistent application/problem+json API responses with configurable type, title, status, detail, and custom extension fields. Ideal for standardizing HTTP API error handling.
Reality check before you start. 0.1.0 is the initial release — there is no prior stable line to upgrade from, no deprecations, and nothing that could break you. What the tag does tell you is the date: an April 2018 first release with no stars and no dependents means the initial cut is also, in practice, the final one. It targets the Symfony 3.3–4.0 / PHP 7.1 era, so on any modern stack (PHP 8.1+, Symfony 6/7) composer require will almost certainly fail on platform or symfony/* constraints, and if you force it you inherit an unmaintained listener running in your exception path. Treat the steps below as "how to evaluate and trial it," and read the last section before committing to production.
The release notes are worth reading closely, because they define the whole surface area in two bullets: an exception listener, and serialization of common exceptions — Doctrine, HttpFoundation and Symfony Security. That second bullet is the actual selling point and the thing to test first.
1. Verify it installs at all.
composer require mnavarrocarter/problem-details-bundle --dry-run
If that resolves, you're on an old enough app that the rest is straightforward. If it doesn't, stop here — the concepts still transfer to a hand-rolled listener.
2. Register the bundle.
Symfony 4 Flex (config/bundles.php):
return [
// ...
MNavarroCarter\ProblemDetailsBundle\ProblemDetailsBundle::class => ['all' => true],
];
Symfony 3.x (app/AppKernel.php):
$bundles = [ /* ... */ new MNavarroCarter\ProblemDetailsBundle\ProblemDetailsBundle() ];
3. First test: throw the exceptions it already knows about. Before you write a line of config, find out what 0.1.0 serializes out of the box. Hit an API route and trigger, in order:
NotFoundHttpException (HttpFoundation) — expect a 404 problem document;EntityNotFoundException / NotFoundHttpException from a repository lookup — expect 404;BadRequestHttpException or MethodNotAllowedHttpException — expect 400/405;Symfony\Component\Security\Core\Exception\AccessDeniedException — expect 403 (see the firewall caveat in Gotchas);curl -i -H 'Accept: application/json' http://localhost:8000/api/widgets/42
{"type":"https://example.com/problems/widget-not-found",
"title":"Widget not found",
"status":404,
"detail":"No widget with id 42",
"instance":"/widgets/42"}
Whatever that zero-config probe produces is your real baseline. Everything else is configuration on top.
4. Where to look first. The repo is small and thinly documented, so read the source in this order:
DependencyInjection/Configuration.php — the real documentation: the config key (expect problem_details:), the debug flag, default type URI, and how the common-exception map is expressed/overridable.EventListener/ — shows you how an exception becomes a response, at what priority, and where the Doctrine/HttpFoundation/Security branches live.Exception/ or Problem/ namespace — the exception types you're expected to throw and the interface your own exceptions should implement.5. First real use case — kill the HTML error page on API routes. Throw a domain exception from a controller and let the listener render it:
public function show($id)
{
$widget = $this->widgets->find($id);
if (!$widget) {
throw new WidgetNotFound($id); // map in config, or implement the bundle's interface
}
return $this->json($widget);
}
If you still get HTML, the listener isn't firing (wrong bundle registration, or your firewall's ExceptionListener handled it first — see Gotchas).
Start from the built-in map, don't reimplement it. The headline feature of 0.1.0 is that Doctrine, HttpFoundation and Symfony Security exceptions already become problem documents. So the pattern is: configure/scope the listener, then write only mapping for your own domain exceptions. If you find yourself writing a normalizer for NotFoundHttpException, stop — you're duplicating the bundle and you'll fight it on priority.
Treat the Doctrine branch as convenient-but-dangerous. Auto-serializing Doctrine exceptions is a huge day-to-day win (no more 500s with stack traces for a missing entity) and simultaneously the biggest liability in the package: DBAL and ORM exception messages routinely contain SQL fragments, table/column names, parameter values, connection DSNs. Wire the debug flag to %kernel.debug% so detail is verbose locally and generic in prod, and spot-check the actual JSON for a UniqueConstraintViolationException and a DriverException before you ship.
Map domain exceptions, don't build responses in controllers. Define one exception per failure mode and let configuration (or an interface method returning status/type) supply the problem shape. Keep detail machine-generated and safe; put human-facing copy in title.
Use type as a stable, documented URI. Point it at real docs (https://api.example.com/problems/validation-failed). Clients switch on type, not title, so never change a type URI after publishing it — add a new one instead. Note the built-in families will have the bundle's notion of type unless you override it; decide early whether you accept those URIs or normalise everything to your own namespace, and document the answer.
Validation errors → 422 with an extension member. RFC 7807 lets you add arbitrary members; this is where field-level errors belong:
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 422,
"errors": {"email": ["This value is not a valid email address."]}
}
Catch the framework's ValidationException (or your own wrapper) in a small mapping layer and attach violations as extensions, rather than letting each form/validator leak its own format.
Scope it to the API. Wrap the listener's work in a guard so browser routes still get Twig error pages and 302 redirects:
if (!str_starts_with($request->getPathInfo(), '/api')) {
return; // let Symfony's normal exception handling continue
}
Otherwise every 404 in the UI becomes a JSON blob — a classic half-day-of-debugging mistake. This matters more with 0.1.0 than with a hand-rolled listener, because the pre-mapped HttpFoundation/Security branches will happily convert front-end 404s and access-denied redirects too.
Always honour Accept. Only emit application/problem+json when the client asked for JSON (or */* from an API route). A curl with Accept: text/html should still get HTML — verify this explicitly, since built-in normalizers tend to be unconditional.
Log at the same seam. In the same listener (or a decorator around it), log 5xx problems with a correlation/request id and put that id in an extension member (trace_id). That turns "user reports a 500" into a one-query investigation, and it's the single highest-value thing to add beyond what the bundle gives you.
Test the contract, not the exception class.
$client->request('GET', '/api/widgets/42');
$this->assertResponseStatusCodeSame(404);
$this->assertResponseHeaderSame('Content-Type', 'application/problem+json');
$this->assertJsonStringEqualsJsonString('{"type":"...","title":"...","status":404,...}', $client->getResponse()->getContent());
These tests survive the inevitable day you replace the bundle with a native listener, which is exactly why they're worth writing. Add one test per built-in family too (Doctrine not-found, HttpFoundation bad-request, Security access-denied) so you have a spec for reimplementing them later.
In dev, keep errors verbose; in prod, strip them. Use the bundle's debug flag (usually wired to %kernel.debug%) or your own guard so detail/stack traces never reach prod responses while staying useful on your machine.
ProblemNormalizer that turns HttpExceptionInterface into application/problem+json; Nelmio's ApiProblemBundle is the maintained community equivalent; API Platform has its own. On a modern Symfony the right "day-to-day use" of this bundle is to read its listener and its Doctrine/HttpFoundation/Security normalizers as a reference implementation and write ~150 lines yourself. You get the same output, no abandoned dependency, and full control over what leaks out of Doctrine messages.detail for those families should be assumed hostile until you've read the normalizer and confirmed a debug gate. Sanitise or override before going anywhere near production.ExceptionListener handles AccessDeniedException/AuthenticationException (redirects for HTML, 401 for JSON) and may return before yours runs — which means the bundle's Security serialization may never actually fire for the cases you care about. Verify ordering with debug:event-dispatcher kernel.exception and set an explicit priority if needed. Pick one of: the firewall's entry point or the bundle.kernel.boot listener, PHP fatal, OOM) never reach kernel.exception. Your error envelope is not a universal safety net.application/problem+json is not application/json. Strict HTTP clients, some SDKs and a few API gateways will reject or fail to parse it. If your consumers are inflexible, configure the bundle (or your listener) to emit application/json with the same body — RFC 7807 §3 permits it, and this is often the pragmatic call.ConstraintViolation instances, Doctrine entities) they may serialize into something unusable or throw. Normalise extensions to scalars/arrays before they hit the response.instance is decoration, not an identifier. It's conventionally the request URI; don't let clients treat it as a resource key.trace_id, and to post-process the built-in Doctrine/Security output without forking), a custom normalizer/serializer for extension members, and a marker interface on your exceptions so mapping lives in code rather than YAML. Prefer decorating over editing vendor code — you will need to migrate off this bundle eventually, and a thin decorator makes that a delete rather than a rewrite.How can I help you explore Laravel packages today?