Operations and dispatch¶
A JSON:API request — once negotiated, parsed and routed — becomes a single
operation value object that you hand to one method you implement. This page is
the model behind that: the nine operation VOs, the Target that
identifies the endpoint, the OperationFactory
that picks the right operation, the OperationContext and
QueryParameters carried alongside, and the
OperationHandlerInterface you write —
plus the PSR-7 adapter that joins
it all to a PSR-15 stack. Read this if you are writing a handler or a custom
integration.
For how the surrounding lifecycle is assembled (the middleware that runs before
your handler), see architecture and middleware.
In order, a request flows through: negotiate content type, parse the body, route
and attach the Target, build the operation with the
OperationFactory, call your
handler, then render its response VO. The
fuller stage-by-stage account is in architecture.
The common contract¶
Every operation implements JsonApiOperationInterface:
three accessors that tell you what endpoint you are on, the query parameters in
effect, and the ambient context.
interface JsonApiOperationInterface
{
public function target(): Target;
public function queryParameters(): QueryParameters;
public function context(): OperationContext;
}
The five mutating operations — create, update, delete-relationship-member, and
the two relationship-mutation verbs — add one more method, body(): JsonApiRequestInterface,
giving you the parsed write document. The other four operations have no body: the
three reads (FetchResourceOperation, FetchRelatedOperation,
FetchRelationshipOperation) plus DeleteResourceOperation.
Dispatching on the concrete operation type (rather than inspecting an HTTP verb)
is the whole point: each verb-crossed-with-shape combination is its own class, so a
match (true) over types is exhaustive and type-safe — the body is present exactly
where the type guarantees it.
The nine operations¶
There are nine concrete operation VOs, one per JSON:API endpoint. The HTTP method
crossed with the Target shape selects which one you receive.
| Operation | Endpoint | Body? |
|---|---|---|
FetchResourceOperation |
GET /albums (collection) or GET /albums/1 (single — target()->hasId()) |
— |
FetchRelatedOperation |
GET /albums/1/artist (the related resource(s)) |
— |
FetchRelationshipOperation |
GET /albums/1/relationships/tracks (linkage) |
— |
CreateResourceOperation |
POST /albums |
yes |
UpdateResourceOperation |
PATCH /albums/1 |
yes |
DeleteResourceOperation |
DELETE /albums/1 |
— |
UpdateRelationshipOperation |
PATCH /tracks/1/relationships/playlists (replace) |
yes |
AddToRelationshipOperation |
POST /tracks/1/relationships/playlists (add) |
yes |
RemoveFromRelationshipOperation |
DELETE /tracks/1/relationships/playlists (remove) |
yes |
A FetchResourceOperation covers both the collection and the single-resource read:
the same operation serves GET /albums and GET /albums/1, and you branch on
target()->id (or the convenience target()->hasId()) inside your handler.
A handler as a match over operation types¶
The music-catalog example app implements exactly one handler. Its handle() is a
match (true) over the nine concrete types — the canonical shape for a handler.
Each arm calls a private method that knows how to service that one operation; the
default arm guards against an unknown operation type with a 404.
From MusicCatalogHandler:
public function handle(
JsonApiOperationInterface $operation,
): DataResponse|RelatedResponse|IdentifierResponse|NoContentResponse|ErrorResponse {
return match (true) {
$operation instanceof FetchResourceOperation => $this->fetch($operation),
$operation instanceof FetchRelatedOperation => $this->fetchRelated($operation),
$operation instanceof FetchRelationshipOperation => $this->fetchRelationship($operation),
$operation instanceof CreateResourceOperation => $this->create($operation),
$operation instanceof UpdateResourceOperation => $this->update($operation),
$operation instanceof DeleteResourceOperation => $this->delete($operation),
$operation instanceof UpdateRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Replace),
$operation instanceof AddToRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Add),
$operation instanceof RemoveFromRelationshipOperation => $this->mutateRelationship($operation, $operation->body(), Mode::Remove),
default => ErrorResponse::fromException(new ResourceNotFound()),
};
}
The handler's return union here lists five response VOs; the sixth allowed return,
the meta-only MetaResponse, is simply one the
music-catalog handler never produces (the full closed union is shown under
OperationHandlerInterface).
Notice the single read arm for collection-or-single, and that the three
relationship-mutation arms read $operation->body() — only available because those
concrete types declare it. Each arm reaches the serializer/hydrator registries
through the operation's context() (see below). What each arm
returns is covered in responses; the read arms are detailed in
related endpoints and the mutation arms in
relationship mutation.
Target¶
Target is the router-agnostic identifier for the
endpoint an operation acts on, independent of PSR-7. It names the primary resource
type, optionally a specific id, and optionally a relationship; a flag
distinguishes the two relationship shapes.
final readonly class Target
{
public function __construct(
public string $type,
public ?string $id = null,
public ?string $relationship = null,
public bool $isRelationshipEndpoint = false,
) {}
public function hasId(): bool { /* … */ }
public function hasRelationship(): bool { /* … */ }
}
It is a leaf value object: the readonly properties are the accessors, with
hasId() / hasRelationship() as convenience predicates. The four endpoint shapes
map onto it directly:
| Path | id |
relationship |
isRelationshipEndpoint |
|---|---|---|---|
/albums |
null |
null |
false |
/albums/1 |
"1" |
null |
false |
/albums/1/artist (related) |
"1" |
"artist" |
false |
/albums/1/relationships/tracks (linkage) |
"1" |
"tracks" |
true |
Routing is your job — the library never parses a path. Your framework's router
(or, in the example, the toy PathPrefixRouter)
builds a Target and attaches it as a PSR-7 request attribute keyed by
Target::class — the single attribute the adapter
reads:
OperationFactory: from request to operation¶
OperationFactory is the single source of
truth for the dispatch decision: given a parsed request, a Target and a context,
it constructs the one concrete operation matching the HTTP method crossed with the
target shape.
public function fromRequest(
JsonApiRequestInterface $request,
Target $target,
OperationContext $context,
): JsonApiOperationInterface {
$query = QueryParameters::fromRequest($request);
$hasRelationship = $target->hasRelationship();
return match (\strtoupper($request->getMethod())) {
'GET' => match (true) {
$hasRelationship === false => new FetchResourceOperation($target, $query, $context),
$target->isRelationshipEndpoint => new FetchRelationshipOperation($target, $query, $context),
default => new FetchRelatedOperation($target, $query, $context),
},
'POST' => $hasRelationship
? new AddToRelationshipOperation($target, $query, $context, $request)
: new CreateResourceOperation($target, $query, $context, $request),
'PATCH' => $hasRelationship
? new UpdateRelationshipOperation($target, $query, $context, $request)
: new UpdateResourceOperation($target, $query, $context, $request),
'DELETE' => $hasRelationship
? new RemoveFromRelationshipOperation($target, $query, $context, $request)
: new DeleteResourceOperation($target, $query, $context),
default => throw new \haddowg\JsonApi\Exception\ApplicationError(),
};
}
It is a public, stateless seam — construct it, then call fromRequest(). A few
deliberate boundaries:
- It takes the already-parsed
JsonApiRequestInterface, so the body source andQueryParameters::fromRequest()read from the same memoized wrapper; wrapping and idempotency stay the caller's responsibility. - It takes the
OperationContextexplicitly — each caller decides which HTTP request (if any) backs the context. - It does not handle a missing target: the signature requires a non-null
Target, keeping the no-route concern at the adapter edge. - An unhandled HTTP method throws
ApplicationError(a500).
Override or wrap OperationFactory if your custom integration needs a different
method-to-operation mapping; the adapter
accepts a factory as its third constructor argument.
OperationContext¶
OperationContext is the ambient context
every operation carries: the server (so a handler can resolve serializers and
hydrators) and, when the operation came from HTTP, the originating PSR-7 request.
final readonly class OperationContext
{
public function __construct(
public ResolvingServerInterface $server,
private ?ServerRequestInterface $httpRequest = null,
) {}
public function httpRequest(): ?ServerRequestInterface { /* … */ }
}
$server is a ResolvingServerInterface
— the render contract plus type-keyed serializer/hydrator resolution
(serializerFor() / hydratorFor()), which is all most handlers need from the
server (see server). The example narrows it to the concrete Server
to reach resourceFor() / defaultPaginator() as well.
The HTTP request is optional and private behind httpRequest(). An operation
dispatched programmatically — constructed directly rather than adapted from a
PSR-7 message — has no HTTP message, so httpRequest() returns null. A handler
that needs the raw request must null-check. The example's helper does exactly this,
so the same handler serves an HTTP request and a programmatic dispatch:
private function request(OperationContext $context): JsonApiRequestInterface
{
$request = $context->httpRequest();
if ($request instanceof JsonApiRequestInterface) {
return $request;
}
// No HTTP message (a programmatic dispatch) — fall back to a bare GET so the
// repository's criteria + window still have a request to read: an unfiltered,
// unsorted, default-windowed read.
return new JsonApiRequest(new \Nyholm\Psr7\ServerRequest('GET', '/'));
}
QueryParameters¶
QueryParameters is the parsed projection of
the JSON:API query-parameter families, decoupled from the request so a handler can
be driven without a PSR-7 message:
final readonly class QueryParameters
{
public function __construct(
public array $fields, // sparse fieldsets, keyed type => field names
public array $includes, // include paths (a flat list)
public array $sort, // sort fields (leading "-" preserved)
public array $filter, // the filter map verbatim
public array $pagination, // the page map verbatim
) {}
}
Each member is the spec-shaped projection of one family; the readonly properties
are the accessors. fromRequest() is just the HTTP-side constructor — it reads
sort / filter / page from the request's own parsers and parses the raw
comma-separated include and fields[type] strings into lists. Malformed values
are tolerated (skipped) rather than thrown; well-formedness is the negotiation
layer's job. A programmatic caller can construct QueryParameters directly instead.
The individual families are documented in sparse fieldsets and includes,
sorts, filters and pagination.
OperationHandlerInterface: the one seam¶
OperationHandlerInterface is the
single consumer extension point of the operations layer. You implement one method;
given any operation, you return one of the public response value objects.
interface OperationHandlerInterface
{
public function handle(
JsonApiOperationInterface $operation,
): DataResponse|MetaResponse|RelatedResponse|IdentifierResponse|NoContentResponse|ErrorResponse;
}
The return type is a closed union of six response VOs — the only things a handler
may produce. The handler stays PSR-7-decoupled (the optional originating request is
reachable through context()->httpRequest()), and the adapter
encodes whatever you return back to PSR-7. The response VOs themselves are covered
in responses. You can also decorate a handler — wrap another
OperationHandlerInterface to add cross-cutting behaviour around the dispatch.
Psr7ToOperationHandlerAdapter: the PSR-15 join¶
Psr7ToOperationHandlerAdapter
is the join between the PSR-15 world and the operations layer. It is a
RequestHandlerInterface that, for each incoming PSR-7 request:
- reads the
Targetfrom the request attribute keyed byTarget::class; - wraps the request as a
JsonApiRequestInterface(if it is not already one); - builds an
OperationContextcarrying the server and the originating request; - asks the
OperationFactoryfor the matching operation; - calls your handler and encodes the returned response VO to PSR-7.
public function handle(ServerRequestInterface $request): ResponseInterface
{
$target = $request->getAttribute(Target::class);
if ($target instanceof Target === false) {
// Routing failed to attach a Target — a server-side wiring fault, not a
// client error. Render a 500 ErrorResponse rather than throwing.
return ErrorResponse::fromException(new \haddowg\JsonApi\Exception\ApplicationError())
->toPsrResponse($this->server, $request);
}
$jsonApiRequest = $request instanceof JsonApiRequestInterface ? $request : new JsonApiRequest($request);
$context = new OperationContext($this->server, $request);
$operation = $this->factory->fromRequest($jsonApiRequest, $target, $context);
$response = $this->handler->handle($operation);
return $response->toPsrResponse($this->server, $request);
}
A missing Target is treated as a wiring fault: the adapter renders a 500
ErrorResponse rather than throwing, so the PSR-15 contract still yields a valid
JSON:API response. This is why the example router answers a genuine no-match with
its own 404 (it simply never attaches a Target), reserving the adapter's 500
for the truly unexpected case of a matched route that failed to attach one.
The adapter is the standard way to put a handler behind a PSR-15 stack. The Symfony
bundle bypasses it: it builds the operation from its own kernel listeners and
calls Server::dispatch() directly.
Two ways to drive a handler¶
The same handler serves two dispatch paths from the Server:
Server::handle($request)runs the full PSR-15 middleware chain — negotiation, body parsing, routing — wrapped around the adapter, and returns an encoded PSR-7ResponseInterface. This is the HTTP entry point.Server::dispatch($operation)invokes the configured handler directly, bypassing the PSR-15 chain, and returns the unrendered response VO. The operation must be pre-constructed and complete, and a handler must be configured (withHandler()), or it throws aLogicException.
dispatch() is the path for programmatic use — a queue worker, a test, an internal
call — where you have no HTTP request. You build the operation yourself:
use haddowg\JsonApi\Operation\FetchResourceOperation;
use haddowg\JsonApi\Operation\OperationContext;
use haddowg\JsonApi\Operation\QueryParameters;
use haddowg\JsonApi\Operation\Target;
$operation = new FetchResourceOperation(
new Target('albums', '1'),
new QueryParameters(fields: [], includes: [], sort: [], filter: [], pagination: []),
new OperationContext($server), // no PSR-7 request — httpRequest() returns null
);
$response = $server->dispatch($operation); // a DataResponse VO, not yet rendered
Because the context carries no HTTP request, context()->httpRequest() returns
null inside the handler — which is exactly why the example's
request() helper falls back to a bare GET. The same handler
code runs unchanged on both paths.
Next¶
- Responses — the six response VOs a handler returns, and how they render.
- Related endpoints and relationship mutation — the read and mutation arms in depth.
- Server — configuring the server,
handle()vsdispatch(), and serializer/hydrator resolution.