Route-scoped error handling¶
On a JSON:API route, every failure becomes a spec-compliant error document — the
application/vnd.api+json media type, a top-level errors array, a string
status, and a stable error code — whether it originated in core (an unknown
filter, a missing resource, a validation failure), in Symfony (a firewall denial,
a routing 404), or anywhere else (an unexpected \Throwable → 500). One listener
owns all of it.
The vocabulary of that document — the Error and ErrorSource value objects, the
ErrorResponse, the InternalServerError::for() seam, the exception catalogue and
its stable codes — is core's. See core
errors-and-exceptions.
This page documents only what the bundle adds: a route-scoped kernel.exception
listener, the Symfony-exception → status mapping, debug gating, and how it composes
with the firewall and logging.
The model: a route-scoped kernel.exception listener¶
ExceptionListener registers on
kernel.exception at priority 128 — high enough to win over Symfony's own
error handling on JSON:API routes — and is route-scoped: it acts only when the
matched route carries the marker _jsonapi (ExceptionListener::ROUTE_MARKER).
Every route the bundle's loader emits stamps that default (see
routing); nothing else does. So on any non-JSON:API route the
listener returns immediately and Symfony handles the error as usual — the bundle
never hijacks the rest of your app.
public function onKernelException(ExceptionEvent $event): void
{
$request = $event->getRequest();
if ($request->attributes->get(self::ROUTE_MARKER) !== true) {
return;
}
// … resolve the server, map the throwable, render the error document
}
When the marker is present, the listener resolves the request's server
(_jsonapi_server, defaulting to the implicit default — see
multi-server-and-testing), maps the throwable to a
core ErrorResponse, renders it to PSR-7 via ErrorResponse::toPsrResponse(), and
bridges that back to an HttpFoundation Response it sets on the event. No
controller, no template — the same render seam the
lifecycle's view listener uses on the success path.
The three mapping arms¶
ExceptionListener::toErrorResponse() is a three-way branch on the throwable:
| Throwable | Mapped via | Status & shape |
|---|---|---|
Core JsonApiExceptionInterface |
ErrorResponse::fromException($throwable) |
The exception's own getErrors() / getStatusCode() — the full core error object(s), source and code intact |
Symfony HttpExceptionInterface |
ErrorResponse::fromErrors($this->httpError(...)) |
A single status-keyed Error with a reason-phrase title (the bundle's match() table) and a debug-only detail |
Anything else (\Throwable) |
InternalServerError::for($throwable, $debug) |
A generic 500, byte-identical to core's own middleware 500 |
Arm 1 — core exceptions render themselves. Anything implementing core's
JsonApiExceptionInterface already knows its status and its error objects, so the
listener hands it straight to ErrorResponse::fromException(). This is the common
case on a JSON:API route: an unknown filter (FILTERING_UNRECOGNIZED, 400), an
unrecognized query-parameter family or an unknown fields[type] sparse-fieldset
member (QUERY_PARAM_UNRECOGNIZED / FIELDSET_MEMBER_UNRECOGNIZED, 400 — strict
query-parameter validation, on by default, see
configuration), a missing resource
(RESOURCE_NOT_FOUND, 404), an unknown relationship (RELATIONSHIP_NOT_EXISTS,
404), a validation failure (VALIDATION_FAILED, 422 — see
validation). The bundle adds nothing to the shape; it only
ensures the document is rendered on the JSON:API route instead of being swallowed
by framework error handling. The example app's
ErrorHandlingTest
witnesses several of these end to end:
// GET /albums/999 — the show route matches, the provider's null fetch becomes 404
$error = $this->errorDocument('/albums/999', 404);
self::assertSame('404', $error['status'] ?? null);
self::assertSame('RESOURCE_NOT_FOUND', $error['code'] ?? null);
// GET /tracks?filter[nope]=x — an unrecognised filter
$error = $this->errorDocument('/tracks?filter[nope]=x', 400);
self::assertSame('400', $error['status'] ?? null);
self::assertSame('FILTERING_UNRECOGNIZED', $error['code'] ?? null);
self::assertSame(['parameter' => 'filter[nope]'], $error['source'] ?? null);
Note the 404 detail: GET /albums/999 returns a JSON:API 404 (not a bare Symfony
404) precisely because the albums show route exists and is JSON:API-scoped — the
request reaches the handler, the provider returns null, and core raises the 404.
A request to a route the loader never emitted (an unknown type) 404s at the router,
before this listener is in scope, and renders as Symfony's default.
Arm 2 — Symfony HTTP exceptions get a status-keyed error. A Symfony
HttpExceptionInterface carries an HTTP status but no JSON:API error object. The
listener builds one with the bundle's own reason-phrase title and — only in debug
— the exception message as detail:
private function httpError(HttpExceptionInterface $throwable): Error
{
$status = $throwable->getStatusCode();
return new Error(
status: (string) $status,
title: $this->reasonPhrase($status),
detail: $this->debug ? $throwable->getMessage() : '',
);
}
The reason-phrase table:
| Status | Title |
|---|---|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 405 | Method Not Allowed |
| 406 | Not Acceptable |
| 409 | Conflict |
| 415 | Unsupported Media Type |
| 422 | Unprocessable Entity |
any other >= 500 |
Server Error |
| anything else | Error |
This arm is where the firewall (401/403) and routing (404, 405) land — see the firewall interplay below.
Between arm 1 and arm 2, the listener consults the application-extensible exception mappers (below). Arm 1 always runs first: a core
JsonApiExceptionInterfaceis never intercepted by a mapper or the config map.
Mapping your own exceptions¶
A domain or third-party exception that is not a core JsonApiExceptionInterface
and not a Symfony HttpExceptionInterface falls to the generic 500 by default.
Two facets let you map it to a JSON:API error without decorating the listener.
Both are consulted after arm 1 and only for a throwable that is not a core
JsonApiExceptionInterface — the invariant being that a core JSON:API exception
always renders natively and is never overridden by a mapper or the config map.
Facet 1 — the json_api.exceptions config map¶
For the common status-only case, map an exception class to an HTTP status in config:
# config/packages/json_api.yaml
json_api:
exceptions:
App\Exception\PaymentRequired: 402
App\Exception\TooManyWidgets: 409
A thrown instance renders as a JSON:API error with that status, the bundle's
reason-phrase title, and — only in debug — the exception message as detail
(identical in shape to the Symfony HTTP-exception arm). When a throwable matches
several mapped classes (a subclass hierarchy), the most-specific (most-derived)
mapped class wins.
Facet 2 — a tagged ExceptionMapperInterface¶
For richer errors (a custom source, meta, multiple error objects, or
conditional status), implement
ExceptionMapperInterface — a
service returns an ErrorResponse, or null to defer to the next mapper:
use haddowg\JsonApi\Response\ErrorResponse;
use haddowg\JsonApi\Schema\Error\Error;
use haddowg\JsonApi\Schema\Error\ErrorSource;
use haddowg\JsonApiBundle\EventListener\ExceptionMapperInterface;
final class PaymentExceptionMapper implements ExceptionMapperInterface
{
public function map(\Throwable $throwable): ?ErrorResponse
{
if (!$throwable instanceof PaymentFailed) {
return null; // defer to the next mapper
}
return ErrorResponse::fromErrors(new Error(
status: '402',
code: 'PAYMENT_FAILED',
title: 'Payment failed',
detail: $throwable->reason(),
source: ErrorSource::fromPointer('/data/attributes/card'),
));
}
}
Any service implementing the interface is auto-tagged
(json_api.exception_mapper) — no manual tagging needed.
Priority and ordering¶
The mappers are consulted in descending tag priority order (default 0),
first non-null ErrorResponse wins. The bundle's config-driven mapper (the facet-1
json_api.exceptions map) is itself registered as a mapper at a low priority
(-1000), so your own mappers (default 0) are always consulted before the
config map. To order two of your own mappers, set an explicit tag priority:
services:
App\Exception\PaymentExceptionMapper:
tags:
- { name: json_api.exception_mapper, priority: 10 }
If no mapper returns a response, the listener falls through to arm 2 (Symfony HTTP
exceptions) and arm 3 (the generic 500) unchanged.
Arm 3 — everything else is a generic 500. Any other \Throwable is delegated
to core's public, stateless InternalServerError::for($throwable, $debug) seam, so
the rendered error object is byte-identical to the one core's own
ErrorHandlerMiddleware produces — the bundle never re-implements the 500 shape.
This is verified directly in the bundle's
ExceptionListenerTest, which
asserts the listener's output equals the seam's output for the same throwable and
debug flag. The throwable is also logged first (see logging below).
Debug gating¶
Whether an error document carries internal detail is gated on kernel.debug,
injected as %kernel.debug%. With debug off (production), the listener
redacts:
- Arm 2 (HTTP exceptions):
detailis the empty string — the exception message never reaches the client. - Arm 3 (generic 500): core's
InternalServerError::for(..., false)emits only the stablestatus/title(500/Internal Server Error), with nocode,detail, ormeta.
With debug on, the 500 becomes verbose: the exception class lands in
meta.exception, with file, line, and a trace, the exception code as code,
and the message as detail. The example suite boots with kernel.debug = false
(the production base case) and asserts no error leaks {exception, file, line,
trace} meta:
foreach (['/albums/999', '/tracks/1/bogus', '/tracks?filter[nope]=x'] as $path) {
$response = $this->handle($path);
// … each error object:
self::assertArrayNotHasKey('exception', $meta);
self::assertArrayNotHasKey('file', $meta);
self::assertArrayNotHasKey('line', $meta);
self::assertArrayNotHasKey('trace', $meta);
}
and the bundle's listener test pins both directions of the 500:
// debug off — redacted
self::assertArrayNotHasKey('detail', $error);
self::assertArrayNotHasKey('meta', $error);
// debug on — verbose
self::assertSame('leaky secret detail', $error['detail'] ?? null);
self::assertSame(\RuntimeException::class, $meta['exception'] ?? null);
Keep kernel.debug off in production, as you would on any Symfony app — the gating
is your protection against leaking secrets in an unexpected-error trace.
Firewall interplay¶
Because the listener is route-scoped and a Symfony security denial is an
HttpExceptionInterface (401/403), a firewall exception thrown on a JSON:API route
renders as a JSON:API error document through arm 2 — not as Symfony's HTML login
redirect or access-denied page. Put a firewall in front of your JSON:API routes and
a 401/403 still comes back as application/vnd.api+json with a 401/403 status
and the matching reason-phrase title. Per-route firewall configuration and
JSON:API error rendering compose with no extra wiring — there is a real route and
real kernel events, so Symfony's security machinery runs normally (see
lifecycle). The example app ships no firewall, so this arm is
exercised in the bundle's own functional suite rather than the example tests.
Logging¶
Unexpected throwables (arm 3 only) are logged before the 500 is rendered:
$this->logger?->error($throwable->getMessage(), ['exception' => $throwable]);
return ErrorResponse::fromErrors(InternalServerError::for($throwable, $this->debug));
The logger service is injected nullOnInvalid(), so the dependency is optional:
if no logger is registered, the null-safe ?-> call is a no-op and the 500 still
renders correctly — but nothing is logged. Core exceptions (arm 1) and HTTP
exceptions (arm 2) are expected outcomes and are not logged; only the genuinely
unexpected \Throwable is. If you rely on 500s reaching your logs, ensure a logger
(monolog, or any Psr\Log\LoggerInterface service) is wired — it is in any standard
Symfony app.
Which path produced your error¶
Two render paths exist, and the code member tells you which:
- A core
code(RESOURCE_NOT_FOUND,FILTERING_UNRECOGNIZED,VALIDATION_FAILED,REQUEST_BODY_INVALID_JSON, …) means arm 1 — the failure was raised by core (or the bundle's own core-style exceptions) with its status,source, and stable code already set. The catalogue of these lives in core errors-and-exceptions. - No
code, just a reason-phrasetitlemeans arm 2 (a Symfony HTTP exception) or arm 3 (Internal Server Error, the generic 500). These are the bundle's status-keyed shapes for failures that originated outside core's vocabulary.
Localizing and overriding error copy¶
Every error's title and detail are message templates core resolves per stable
error code
(core ADR 0128).
The bundle binds that seam to the Symfony translator automatically: when
symfony/translation is installed, any error's copy can be localized or rebranded
through ordinary translation files in the jsonapi_errors domain, keyed by code:
# translations/jsonapi_errors.fr.yaml
RESOURCE_NOT_FOUND:
title: Ressource introuvable
MEDIA_TYPE_UNSUPPORTED:
detail: "Le type de média '{mediaType}' n'est pas supporté."
VALIDATION_FAILED:
title: Entité non traitable
Only the human copy moves: an error's code and status are never touched, and a
key you don't provide falls back to core's inline English — per slot, so a partial
translation is fine. The values are templates: a {placeholder} is filled from
the error's context after translation (a media type, an id), so write {mediaType},
not Symfony's %mediaType% (no parameters are passed to the translator). The
placeholder names available per code are listed in core's
errors-and-exceptions.
The lookup uses the translator's current locale, so Accept-Language negotiation is
the framework's job — wire Symfony's usual locale resolution and each request renders
in its negotiated language. Because the resolver is applied uniformly to every error,
the validator's 422 VALIDATION_FAILED title (arm 1) localizes through the very same
domain. Without symfony/translation the seam is inert and errors render in English
exactly as before.
Next / see also¶
- lifecycle — the success path this listener mirrors:
kernel.request→JsonApiController→kernel.view, and where negotiation raises the 415/406/400 errors this listener then renders. - routing — where the
_jsonapiroute marker (and_jsonapi_server) are stamped, and why an unknown type 404s at the router before this listener. - validation — how a write failure becomes the
422VALIDATION_FAILEDdocument arm 1 renders. - multi-server-and-testing — how
_jsonapi_serverresolves to the per-serverServerthe error document is rendered against. - Core errors-and-exceptions
(the exception catalogue,
Error/ErrorSource,ErrorResponse,InternalServerError::for, andJsonApiExceptionInterface).