libero/api-problem-bundle
Libero API Problem Bundle adds RFC 7807 Problem Details support to Symfony APIs, helping you return consistent, machine-readable error responses. Map exceptions to standard problem types and keep JSON error payloads predictable across services.
This is a Symfony bundle, not a Laravel package — it hooks Symfony's kernel.exception event to render exceptions as application/problem+json. If you're coming from Laravel, read it as "the Handler::render() equivalent, packaged as a listener + problem factory" and borrow the patterns rather than expecting a config/api-problem.php.
Minimum steps
composer require libero/api-problem-bundle:0.2.0
// Symfony 4+ config/bundles.php
Libero\ApiProblemBundle\ApiProblemBundle::class => ['all' => true],
// Symfony 3.x app/AppKernel.php
new Libero\ApiProblemBundle\ApiProblemBundle(),
curl -i -H 'Accept: application/problem+json' https://localhost/api/nope
Expect HTTP/1.1 404, Content-Type: application/problem+json, and a body with type, title, status, detail, instance.Where to look first (day one)
kernel.exception listener — check its priority. If Symfony's default ExceptionListener/ErrorListener still wins, you'll get HTML or the debug page.type URI.First real use case: stop returning ad-hoc {"error": "..."} shapes. Define one custom exception per client-visible failure (ResourceNotFound, InvalidStateTransition, QuotaExceeded), map each to a stable type URI, throw them from controllers/services, and let the bundle serialize them. Clients then branch on type, not on English detail text.
1. Exception-per-problem, stable type URIs
Treat type as part of your public API contract. Point them at documentation pages you actually host:
throw new ProblemException(
type: 'https://api.example.com/problems/quota-exceeded',
title: 'Quota exceeded',
status: 429,
detail: sprintf('Plan %s allows %d requests/day.', $plan, $limit),
);
Version the URIs, never the wording. Changing title should be non-breaking; changing type is breaking.
2. Map framework exceptions explicitly
Don't let Symfony's NotFoundHttpException leak its own title/detail if you want consistent copy. Add a small resolver/listener that translates the handful of Symfony exceptions you actually emit (NotFoundHttpException, AccessDeniedException, MethodNotAllowedHttpException, HttpExceptionInterface generally) into your problem types, preserving the original status code.
3. Validation → violations extension
For ConstraintViolationListInterface, emit the RFC 7807bis-style extension and a single problem type:
{
"type": "https://api.example.com/problems/validation-failed",
"title": "Validation failed",
"status": 400,
"violations": [
{ "propertyPath": "email", "message": "This value is not a valid email address." }
]
}
Normalize propertyPath to dot/bracket form (items[3].sku) at the boundary — Symfony gives you the raw path, and clients build form-field mappings from it. Never localize message server-side if clients render their own copy; ship a stable code alongside it if you need i18n.
4. Extensions for operational context
Use extension members (traceId, retryAfter, requestId, docs) rather than stuffing them into detail. Add them from a listener/subscriber so controllers don't care:
$problem = $problem->withExtension('traceId', $request->attributes->get('trace_id'));
This also gives you a free correlation key between the JSON body and your logs.
5. Logging without double-logging
v0.2.0 added logging. Make sure it logs at error only for 5xx and at info/warning for 4xx, includes the traceId, and does not include the request body or the exception message verbatim for 4xx (user input). If Symfony's own ErrorListener already logs, either lower its verbosity or accept two entries — just know which one your alerting keys on.
6. Testing contract, not prose In functional tests, assert the envelope, not the sentence:
$client->request('GET', '/api/widgets/999', [], [], ['HTTP_ACCEPT' => 'application/problem+json']);
$response = $client->getResponse();
self::assertSame(404, $response->getStatusCode());
self::assertStringStartsWith('application/problem+json', $response->headers->get('Content-Type'));
self::assertJsonStringEqualsJsonString(
'{"type":"https://api.example.com/problems/not-found","status":404}',
// or use json_encode on a decoded subset
json_encode(array_intersect_key(
json_decode($response->getContent(), true),
['type' => true, 'status' => true]
))
);
Add one test per problem type — this becomes your de-facto schema registry.
7. Keep it behind an adapter boundary
Because the bundle is pre-1.0 and archived, don't scatter ProblemException throws across your domain. Either:
ApiProblemFactory interface in your app and let the bundle be one implementation.Six months from now, swapping the bundle for 40 lines of your own listener should touch one file.
0.2.0 exactly, no ^. No security patches, no Symfony 5/6/7 support, no PHP 8.1+ fixes. If you're on a modern stack, budget a few hours to either vendor-patch or reimplement — the RFC 7807 logic is genuinely ~100 lines.Accept: application/json / application/problem+json are likely to get JSON; browsers hitting the same route may still get HTML. Test with the header your SPA/mobile client actually sends, and consider forcing JSON for /api/* via a format listener or route requirement.dev, Symfony's ErrorHandler/profiler intercepts before your listener and shows the exception page. Always verify problem rendering in prod-like config — this is the #1 "it doesn't work" cause.HttpException gets rendered by the framework first. Check priorities and verify one 404 and one 500 end-to-end.kernel.exception only covers exceptions. PHP fatals, exit, and out-of-memory errors produce a 500 without the problem body — that's a gap in the envelope, not a bug to chase.type, title, status, detail, instance cannot be reused as extension keys. If you write a generic withExtension() helper, guard against collisions or you'll silently overwrite status.detail leaks. Exception messages routinely contain SQL, file paths, or internal hostnames. Default to a generic detail for 5xx in prod and log the real message; keep a debug flag that flips this only off-prod.status in the body must match the HTTP status. They're separate fields; a mismatch breaks strict clients. Assert this in tests.instance should be the request URI, not a UUID — some clients use it for dedup. If you want a correlation ID, add traceId as an extension instead.Cache-Control: no-store; a 429 with Retry-After is the exception where you do want caching/reuse guidance.title/detail server-side unless your API is single-locale. type + optional code extension are stable; prose is not.How can I help you explore Laravel packages today?