Errors and the exception catalogue¶
When a request goes wrong, JSON:API responds with a document carrying errors
instead of data. In haddowg/json-api you produce one the same way you signal
any failure in PHP: you throw a typed exception anywhere downstream. The
outermost ErrorHandlerMiddleware catches it, turns it into an
ErrorResponse, and renders a spec-compliant error document whose
HTTP status is the one the exception declares. You almost never assemble an error
document by hand. This page covers how errors propagate, the alternative
return-an-error path, the generic 500 for unexpected failures, and the full typed
exception catalogue.
The model: throw a typed exception¶
Every failure the library knows about is a typed exception carrying its own JSON:API error data and HTTP status. Throwing one is enough — the error handler reads the data off it and renders the document:
throw SomeJsonApiException
→ ErrorHandlerMiddleware catches it (outermost in the chain)
→ ErrorResponse::fromException($exception)
→ a JSON:API error document (HTTP status from the exception)
Because the error handler wraps the whole chain, this works from anywhere
downstream — a middleware, your data adapter, your handler, a serializer, a
hydrator. The library's own request parsing, content negotiation, and hydration
throw these same exceptions, so a malformed request renders as a proper error
document with no code on your part. In the worked
MusicCatalogHandler
a missing resource is just a throw:
use haddowg\JsonApi\Exception\ResourceNotFound;
$model = $this->repository->fetchOne($type, $id);
if ($model === null) {
return ErrorResponse::fromException(new ResourceNotFound()); // → 404 error document
}
Every typed exception implements
Exception\JsonApiExceptionInterface, which exposes the error
data (getErrors(): list<Error>) and the HTTP status (getStatusCode(): int).
ErrorResponse::fromException() reads both, so throwing carries everything the
document needs.
The alternative: return an error from a handler¶
A handler may return an ErrorResponse instead of throwing — both reach the
same renderer. fromException() wraps a typed exception; fromErrors() builds the
document from Schema\Error\Error value objects you construct by hand:
use haddowg\JsonApi\Response\ErrorResponse;
use haddowg\JsonApi\Schema\Error\Error;
use haddowg\JsonApi\Schema\Error\ErrorSource;
return ErrorResponse::fromException(new ResourceNotFound());
return ErrorResponse::fromErrors(
new Error(
status: '422',
code: 'TITLE_TAKEN',
title: 'Validation failed',
detail: 'A playlist with this name already exists.',
source: ErrorSource::fromPointer('/data/attributes/name'),
),
);
A returned ErrorResponse is rendered by the operations adapter like any other
response value object; a thrown exception is rendered by the error
handler. Use whichever reads better at the call site — throw for "stop here,
this is wrong" (it short-circuits the call stack), return for "this branch
produces an error" alongside the success branches. The MusicCatalogHandler does
both: it returns ErrorResponse::fromException(new ResourceNotFound()) from its
read/write arms (the error sits beside the DataResponse returns), and it throws
the relationship-mutation 403s deep inside a helper where unwinding the stack is
the point.
When fromErrors() carries errors that share one HTTP status, that status is used;
a mix is rounded to the nearest applicable class (e.g. a 422 and a 400 round to
400). fromException() always uses the status the exception declares.
Building an Error by hand¶
Schema\Error\Error is a construct-only, immutable value
object. Every member is optional per the spec; an absent string member is the
empty string and an absent structured member is null, and each is omitted from
the rendered document. Use named arguments:
| Member | Type | Purpose |
|---|---|---|
id |
string |
A unique identifier for this occurrence of the problem. |
status |
string |
The HTTP status as a string ('422'). |
code |
string |
An application-specific error code. |
title |
string |
A short, human-readable summary that does not vary per occurrence. |
detail |
string |
A human-readable explanation specific to this occurrence. |
source |
?ErrorSource |
Locates the cause in the request (see below). |
links |
?ErrorLinks |
An about/type link for the error. |
meta |
array<string, mixed> |
Non-standard meta-information. |
context |
array<string, scalar\|Stringable> |
Occurrence values interpolated into the title/detail templates — internal, never serialized. See Localizing and overriding error copy. |
The source member points at the part of the request that caused the error. It
has three mutually exclusive constructors — pick the one matching where the cause
lives:
| Constructor | Member emitted | Locates the cause in |
|---|---|---|
ErrorSource::fromPointer('/data/attributes/name') |
pointer |
the request document body (a JSON Pointer, RFC 6901) |
ErrorSource::fromParameter('filter[genre]') |
parameter |
a query parameter |
ErrorSource::fromHeader('Accept') |
header |
a request header |
Localizing and overriding error copy¶
Every error's title and detail are message templates resolved at render
time. By default an error renders its own inline copy — the English catalogue — so
nothing changes until you bind a resolver. Bind one on the Server to localize or
rebrand the copy per error code:
use haddowg\JsonApi\Schema\Error\ErrorMessageResolverInterface;
$server = Server::make()->withErrorMessageResolver(
new class implements ErrorMessageResolverInterface {
public function title(string $code): ?string
{
return ['RESOURCE_NOT_FOUND' => 'Introuvable'][$code] ?? null;
}
public function detail(string $code): ?string
{
return [
'MEDIA_TYPE_UNSUPPORTED' => "Le type de média '{mediaType}' n'est pas supporté.",
][$code] ?? null;
}
},
);
The resolver is consulted once per error, keyed by its stable code. It returns a
(possibly localized) template, or null to keep that slot's default — so a
partial translation degrades gracefully per slot. The render layer then interpolates
the error's context into the template: a {placeholder} token is replaced by the
matching context value (a media type, an id — the occurrence-specific, locale-
invariant parameters each catalogue exception supplies), PSR-3 style. A token with no
matching key is left literal — the render path never throws.
Only the human copy is resolvable. An error's code and status are the machine and
HTTP contract and are never overridden — the interface exposes only title and
detail. And the resolver is applied uniformly to every error in the response, so
a coded error an integration builds itself — for example a validator's 422 whose
title is Unprocessable Entity under code VALIDATION_FAILED — is localized through
the same seam, with nothing special-cased.
This is core's whole job here. Locale negotiation — turning an
Accept-Languageheader into the current locale — is the framework's concern, not core's. A framework integration binds a thin resolver over its own translator (lang/errors.php,translations/errors.*.yaml), which is already locale-aware, and core interpolates whatever template that translator returns. See the Symfony and Laravel integrations.
The available {placeholder} names are the keys of each error's context. The
catalogue populates context for the dynamic parameters in its messages — for
MEDIA_TYPE_UNSUPPORTED, {mediaType} and {header}; for a relationship prohibition,
{relationship}; for an unrecognized filter, {filter} — so a translated template can
reference them. An error whose detail is caller-supplied or built from a list carries
no such parameters: its title still localizes, and its detail can still be overridden
with a static string.
Unexpected throwables: the generic 500¶
The error handler catches any \Throwable, not just JsonApiExceptionInterface.
A non-JSON:API throwable (a bug, a database failure, a third-party error) becomes a
generic 500 Internal Server Error document, so the client always receives valid
JSON:API. What that 500 contains depends on the $debug flag passed to
ErrorHandlerMiddleware:
$debug = false(default, production) — a redacted error:status: "500",title: "Internal Server Error", nothing else. The original message, file, and trace never leak.$debug = true(development) — the throwable's message becomes the errordetail, its non-zero code becomes the errorcode, and the diagnostics go into the error object'smeta:
{
"errors": [
{
"status": "500",
"title": "Internal Server Error",
"detail": "Undefined method ...",
"meta": {
"exception": "RuntimeException",
"file": "/app/src/...",
"line": 42,
"trace": [ ... ]
}
}
]
}
The diagnostics live in meta because that is the spec-faithful home: an error
object's source locates request parts and there is no standard member for a
stack trace. The trace has its call arguments stripped from every frame (so no
request value or secret rides along in the trace), mirroring
laravel-json-api/exceptions.
The mapping itself is a public, stateless seam,
Schema\Error\InternalServerError::for(\Throwable $throwable, bool $debug = false): Error,
so a framework integration that owns its own error handling produces the exact same
generic-500 error object without re-implementing it. The seam is pure: it returns
the single Error value object and does not log, derive an HTTP status, or
build a response — the caller wraps it (ErrorResponse::fromErrors(...)) and logs
as it sees fit.
If you pass a PSR-3 LoggerInterface to the error handler, every unexpected
throwable is also logged at error level — regardless of the debug flag — with
the throwable under the exception log context. Typed JsonApiExceptionInterface
throwables are not logged: they are expected, client-facing outcomes.
Leave
$debugoff in production. The trace, message, and file path can disclose internals.
The contract¶
Every typed exception implements Exception\JsonApiExceptionInterface, which
extends \Throwable and adds two methods:
interface JsonApiExceptionInterface extends \Throwable
{
/** @return list<Error> the JSON:API error objects describing what went wrong */
public function getErrors(): array;
/** The HTTP status code the response should carry. */
public function getStatusCode(): int;
}
The exception exposes the error data and the status — it never builds a document; assembling the document is the serialization layer's job. There is no factory to inject and no indirection: you throw the exception directly and the library catches it.
Exception\AbstractJsonApiException is the base for every concrete class. It
extends \Exception, takes (string $message, int $statusCode), forwards both to
parent::__construct() (so getCode() mirrors the status), and implements
getStatusCode(); each subclass implements getErrors(). You only touch this base
when you write your own exception.
Writing your own exception¶
Domain-specific failures fit the same model. Extend AbstractJsonApiException,
pass your message and status up, and return your error data. The example app's
PaymentRequired is
the worked witness — an HTTP 402 the catalogue itself never defines:
use haddowg\JsonApi\Exception\AbstractJsonApiException;
use haddowg\JsonApi\Schema\Error\Error;
final class PaymentRequired extends AbstractJsonApiException
{
public function __construct(string $detail = 'This operation requires an active premium subscription.')
{
parent::__construct($detail, 402);
}
public function getErrors(): array
{
return [
new Error(
status: '402',
code: 'PAYMENT_REQUIRED',
title: 'Payment required',
detail: $this->getMessage(),
),
];
}
}
The handler throws it when a write demands a capability the caller lacks —
creating a private playlist without the premium flag, in guardPremium(). It
then flows through the same ErrorHandlerMiddleware as the
built-in catalogue and renders a spec-compliant 402 with no special-casing.
Reference global classes like \Exception with a leading backslash inline, matching
the codebase style.
The exception catalogue¶
Every concrete exception lives under the haddowg\JsonApi\Exception namespace. The
status is the HTTP status the response carries; the code is the JSON:API
error object's code member; the source column names the error object's
source kind (a pointer into the body, a query parameter, or none).
Request body & document structure¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
DataMemberMissing |
400 | DATA_MEMBER_MISSING |
pointer | The top-level data member is absent where required. |
ResourceTypeMissing |
400 | RESOURCE_TYPE_MISSING |
pointer | A resource object has no type. |
ResourceIdMissing |
400 | RESOURCE_ID_MISSING |
pointer | A resource object that must carry an id has none. |
ResourceIdInvalid |
400 | RESOURCE_ID_INVALID |
pointer | The id member is present but not a string. |
RequestBodyInvalidJson |
400 | REQUEST_BODY_INVALID_JSON |
— | The request body is not well-formed JSON. |
RequestBodyInvalidJsonApi |
400 | REQUEST_BODY_INVALID_JSON_API |
pointer | The body fails JSON:API schema validation. |
RequiredTopLevelMembersMissing |
400 | REQUIRED_TOP_LEVEL_MEMBERS_MISSING |
— | The document has none of data, errors, meta. |
TopLevelMemberNotAllowed |
400 | TOP_LEVEL_MEMBER_NOT_ALLOWED |
— | included is present without a top-level data. |
TopLevelMembersIncompatible |
400 | TOP_LEVEL_MEMBERS_INCOMPATIBLE |
— | data and errors coexist in one document. |
Resource identifiers¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
ResourceIdentifierTypeMissing |
400 | RESOURCE_IDENTIFIER_TYPE_MISSING |
— | A resource identifier has no type. |
ResourceIdentifierTypeInvalid |
400 | RESOURCE_IDENTIFIER_TYPE_INVALID |
— | A resource identifier's type is not a string. |
ResourceIdentifierIdMissing |
400 | RESOURCE_IDENTIFIER_ID_MISSING |
— | A resource identifier carries neither id nor lid. |
ResourceIdentifierIdInvalid |
400 | RESOURCE_IDENTIFIER_ID_INVALID |
— | A resource identifier's id is not a string. |
ResourceIdentifierLidInvalid |
400 | RESOURCE_IDENTIFIER_LID_INVALID |
— | A resource identifier's lid is not a string. |
Client-generated ids¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
ClientGeneratedIdNotSupported |
403 | CLIENT_GENERATED_ID_NOT_SUPPORTED |
pointer | The client supplied an id but the type does not accept client-generated ids. |
ClientGeneratedIdRequired |
403 | CLIENT_GENERATED_ID_REQUIRED |
pointer | The type requires a client-generated id and none was supplied. |
ClientGeneratedIdAlreadyExists |
409 | CLIENT_GENERATED_ID_ALREADY_EXISTS |
pointer | The supplied client-generated id is already in use. |
Relationships¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
RelationshipNotExists |
404 | RELATIONSHIP_NOT_EXISTS |
— | The requested relationship does not exist on the resource (or its endpoint is suppressed). |
RelationshipTypeInappropriate |
400 | RELATIONSHIP_TYPE_INAPPROPRIATE |
pointer | A relationship's linkage has the wrong cardinality (e.g. a POST/DELETE against a to-one). |
FullReplacementProhibited |
403 | FULL_REPLACEMENT_PROHIBITED |
pointer | A full replacement of a relationship that forbids it is attempted. |
AdditionProhibited |
403 | ADDITION_PROHIBITED |
pointer | An addition to a relationship that forbids it is attempted. |
RemovalProhibited |
403 | REMOVAL_PROHIBITED |
pointer | A removal from a relationship that forbids it is attempted. |
RelatedAttributeOwnerMissing |
422 | RELATED_ATTRIBUTE_OWNER_MISSING |
pointer (/data/attributes/<name>) |
A write targets an attribute flattened on() a to-one relation whose related model is absent (require-exists — no auto-instantiate). |
ResourceTypeUnacceptable |
409 | RESOURCE_TYPE_UNACCEPTABLE |
pointer | A resource type is not a string or is rejected by the hydrator. |
Query parameters¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
QueryParamMalformed |
400 | QUERY_PARAM_MALFORMED |
parameter | A query parameter's value is malformed. |
QueryParamUnrecognized |
400 | QUERY_PARAM_UNRECOGNIZED |
parameter | A query parameter is not recognized. |
FilterParamUnrecognized |
400 | FILTERING_UNRECOGNIZED |
parameter | A filter[...] key is not recognized for the type. |
InclusionUnsupported |
400 | INCLUSION_UNSUPPORTED |
parameter | The endpoint does not support include. |
InclusionUnrecognized |
400 | INCLUSION_UNRECOGNIZED |
parameter | An include path is not recognized for the type. |
InclusionNotAllowed |
400 | INCLUSION_NOT_ALLOWED |
parameter | An include path names a relation marked cannotBeIncluded(), or is outside the root's getAllowedIncludePaths() whitelist. |
InclusionDepthExceeded |
400 | INCLUSION_DEPTH_EXCEEDED |
parameter | An include path is deeper than the effective maximum include depth. |
SortingUnsupported |
400 | SORTING_UNSUPPORTED |
parameter | The endpoint does not support sort. |
SortParamUnrecognized |
400 | SORTING_UNRECOGNIZED |
parameter | A sort field is not recognized. |
Two codes deliberately differ from their class name:
FilterParamUnrecognizedcarriesFILTERING_UNRECOGNIZEDandSortParamUnrecognizedcarriesSORTING_UNRECOGNIZED. Match the code string exactly when you assert on it — do not derive it from the class name.
Content negotiation¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
MediaTypeUnsupported |
415 | MEDIA_TYPE_UNSUPPORTED |
parameter | The request Content-Type media type is not supported. |
MediaTypeUnacceptable |
406 | MEDIA_TYPE_UNACCEPTABLE |
parameter | No Accept media type can be satisfied. |
The 415 / 406 asymmetry is intentional — see
content negotiation for why an unsupported
Content-Type and an unsatisfiable Accept get different statuses.
Resource & lifecycle¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
ResourceNotFound |
404 | RESOURCE_NOT_FOUND |
— | The requested resource does not exist. |
NoResourceRegistered |
500 | NO_RESOURCE_REGISTERED |
— | A serializer/hydrator is requested for an unregistered type — a wiring fault. |
ApplicationError |
500 | APPLICATION_ERROR |
— | A generic application error (e.g. routing failed to attach a Target). |
Response side (defensive, dev/CI)¶
| Exception | Status | Error code | Source | Thrown when |
|---|---|---|---|---|
ResponseBodyInvalidJson |
500 | RESPONSE_BODY_INVALID_JSON |
— | A rendered response body is not well-formed JSON. |
ResponseBodyInvalidJsonApi |
500 | RESPONSE_BODY_INVALID_JSON_API |
pointer | A rendered response fails JSON:API schema validation. |
The two response-side exceptions surface only when the optional
ResponseValidationMiddleware is wired — a development/CI guard
that validates your own output against the spec.
5xx signals a server fault, not a client mistake.
NoResourceRegistered,ApplicationError, and the two response-side exceptions mean a configuration or internal problem — a type used without being registered on the server, routing that did not attach aTarget, output your code produced that does not validate. They still render as proper error documents so the client receives valid JSON:API, but they are your bug to fix, never the client's.
What the error handler does not do¶
The error handler does not inspect a successful return value. A PSR-15 handler
can only return a PSR-7 response, and the response value objects are
not ResponseInterface, so a returned DataResponse/ErrorResponse/etc. is
rendered by the operations adapter, not here. A successful PSR-7 response passes
through the error handler unchanged. The handler's one job is to catch what is
thrown.
Next / See also¶
- Middleware — where
ErrorHandlerMiddlewaresits and why it must be outermost. - Responses —
ErrorResponseand the success-side response value objects. - Concepts — the
ErrorandErrorSourcevalue objects in the wider model. - Content negotiation — the
415/406asymmetry in full. - Documentation index — the full page list.