Skip to content

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-Language header 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.

Which placeholders a code offers

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.

Each code publishes its own placeholder shape on its descriptor, which is the authoritative list to write a template against:

use haddowg\JsonApi\Exception\ErrorCatalog;
use haddowg\JsonApi\Exception\InclusionDepthExceeded;

InclusionDepthExceeded::describe()->context;
// ['paths' => ErrorContextType::Str, 'maxDepth' => ErrorContextType::Integer]

foreach (ErrorCatalog::core()->descriptors() as $descriptor) {
    // $descriptor->code => array_keys($descriptor->context)
}

Deliberately, none of this reaches the generated OpenAPI document. Context is interpolated into title and detail before the response is sent and never appears on the wire, so a document describing what a client receives has nothing to say about it. The placeholders are a PHP-side authoring API and live here, where you are writing the replacement templates.

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 error detail, its non-zero code becomes the error code, and the diagnostics go into the error object's meta:
{
    "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 $debug off 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.

Describing an error code

getErrors() builds its error objects at throw time from constructor state, so nothing can read the catalogue off a class without inventing arguments for it. A second, optional contract closes that gap:

interface DescribedErrorInterface extends JsonApiExceptionInterface
{
    /** The invariant description of this exception's error code. */
    public static function describe(): ErrorDescriptor;
}

An ErrorDescriptor carries the parts that are the same for every occurrence: the code, the status, the default title, the names and types of the context placeholders, the source member the exception always fills, and the ErrorFeature (if any) the error depends on. Every exception core ships implements it and renders its errors through ErrorDescriptor::toError(), so the description and the rendered error cannot disagree.

That is what lets the OpenAPI projection publish a named schema component per code without ever constructing an exception. Describing your own exception is opt-in and costs nothing if you skip it.

Contributing your codes to the catalogue

ErrorCatalog is assembled from sources, each of which yields described-error classes:

interface ErrorCatalogSourceInterface
{
    /** @return iterable<class-string<DescribedErrorInterface>> */
    public function describedErrors(): iterable;
}

Core's own codes come from CoreErrorSource, which reads the classes filed under src/Exception at projection time. ErrorCatalog::core() is that source on its own — the catalogue as core ships it.

Your codes join by adding a source. ClassListErrorSource takes the classes you name, and anything else you write implements the one method:

use haddowg\JsonApi\Exception\ClassListErrorSource;
use haddowg\JsonApi\Exception\CoreErrorSource;
use haddowg\JsonApi\Exception\ErrorCatalog;

$catalog = new ErrorCatalog(
    new CoreErrorSource(),
    new ClassListErrorSource(PaymentRequired::class, SubscriptionExpired::class),
);

The catalogue merges sources in order and drops a class two of them both name. Two different classes claiming the same code is refused with a \LogicException naming both: a code is the identifier a client dispatches on, so quietly letting one win would publish one class's status and title under the other's code.

A source reaches a generated document through the server's OpenAPI metadata. Implement OpenApi\Metadata\ContributesErrorCodes — ServerMetadataInterface plus an errorSources(): iterable — and the projector puts those sources behind core's when it builds that server's catalogue. Contribution is per server, so your codes appear only in the documents of servers that offer them. The Symfony and Laravel integrations wire this from their own discovery.

ErrorFeature gates core's codes on core's capabilities and is a closed enum; a contributed descriptor leaves feature at null and is published on every server that registers it.

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.

Add implements DescribedErrorInterface and a static describe() to have the code join the projected catalogue, then point a source at it — see the worked example. Without that the exception still works exactly as above; it simply reaches a generated client as a plain code string.

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: FilterParamUnrecognized carries FILTERING_UNRECOGNIZED and SortParamUnrecognized carries SORTING_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 a Target, 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 ErrorHandlerMiddleware sits and why it must be outermost.
  • Responses — ErrorResponse and the success-side response value objects.
  • Concepts — the Error and ErrorSource value objects in the wider model.
  • Content negotiation — the 415/406 asymmetry in full.
  • Documentation index — the full page list.