Composing a type from independent capabilities¶
AbstractResource is the on-ramp: one class declares the fields,
relations, serializer and hydrator for a type, and registering it as a service gives
you the full endpoint set. But a JSON:API type is not an AbstractResource — it
is a set of independent capabilities, each of which the bundle discovers and
wires on its own:
| Capability | What it does | Registered by |
|---|---|---|
| serializer | the read wire shape (primary data, linkage, included) |
#[AsJsonApiSerializer] |
| hydrator | the write wire shape (id + attributes from the request body) | #[AsJsonApiHydrator] |
| relations | the type's relationships (for relationship/related endpoints) | #[AsJsonApiRelations] |
| provider | reads — fetch one / collection / related collection | a DataProviderInterface service (data layer) |
| persister | writes — create / update / delete / relationship mutation | a DataPersisterInterface service (data layer) |
AbstractResource is pure Symfony-side sugar that bundles the first three from one
declaration. Nothing is coupled to it. Which endpoints a type serves is governed
by its operation allow-list (and the
readOnly shorthand): you
remove a read or a write by not declaring its operation, not by omitting a
capability. The capabilities you do declare must then back the operations you
did expose — a routed read needs a provider, a routed write needs a
hydrator/persister, and a missing one is a build-time error, not silent
degradation (see the servability guard
below). A serializer alone, with no operations opened, is a serialize-only embedded
type that serves no routes at all. The core library owns the thesis (a type composed
of independent capabilities); this page documents the Symfony wiring of it. For the
model itself, read core's
capability-composition.
The three standalone attributes¶
A capability that lives apart from a resource is registered by an attribute on its
class. Each attribute is TARGET_CLASS, keyed by JSON:API type, and autoconfigures
a public service tag so any class in your src/ that carries it is discovered with no
extra wiring:
| Attribute | Goes on | Tag | Constant |
|---|---|---|---|
#[AsJsonApiSerializer(type, operations, server)] |
a core SerializerInterface |
haddowg.json_api.serializer |
JsonApiBundle::SERIALIZER_TAG |
#[AsJsonApiHydrator(type, server)] |
a core HydratorInterface |
haddowg.json_api.hydrator |
JsonApiBundle::HYDRATOR_TAG |
#[AsJsonApiRelations(type, server)] |
a bundle RelationsProviderInterface |
haddowg.json_api.relations |
JsonApiBundle::RELATIONS_TAG |
The interfaces these sit on are core's — see core's
serializers,
hydrators and
relations for what
each must implement. RelationsProviderInterface is the bundle's own one-method seam
(relations(): list<RelationInterface>); the RelationInterface objects it returns
are core's. The server argument assigns the type to one or more named servers (a
single name, a list, or unset for the implicit default); see
multi-server-and-testing.
A standalone serializer is the most common: a hand-written serializer for a type
that has no resource and no entity. The example app's charts type is exactly this —
the field DSL can't express its verbatim entries list, and there's no Chart table
behind it:
// examples/music-catalog-symfony/src/Serializer/ChartSerializer.php
#[AsJsonApiSerializer(type: 'charts', operations: [Operation::FetchCollection, Operation::FetchOne])]
final class ChartSerializer extends AbstractSerializer implements UriTypeAwareInterface
{
public function uriType(): string
{
return 'charts';
}
public function getType(mixed $object): string
{
return 'charts';
}
// … the remaining SerializerInterface methods, hand-written
}
A standalone serializer implements UriTypeAwareInterface and returns its uriType()
because it has no AbstractResource to supply the URL path segment otherwise — without
it the segment falls back to getType() (see
custom-serializers-hydrators).
There is no charts resource, no entity, no hydrator — just this serializer (the
wire shape) plus a small custom ChartProvider
(the data). That alone makes charts a read-only, fetchable type. See
ChartSerializer
and the equivalent CountrySerializer
(a symfony/intl-sourced reference list — covered in
custom-data-providers).
A class may carry more than one of these attributes: a single class that implements
both SerializerInterface and HydratorInterface can bear #[AsJsonApiSerializer]
and #[AsJsonApiHydrator] together, registering both halves of a resource-less type
in one place.
The default-operations asymmetry¶
This is the one footgun to internalise. A standalone serializer exposes no endpoints
by default; an AbstractResource exposes all five.
| Type kind | Default operations |
|---|---|
AbstractResource |
all five (FetchCollection, FetchOne, Create, Update, Delete) |
standalone #[AsJsonApiSerializer] |
none — serialize-only |
A standalone serializer defaults to serialize-only because the classic use is an
embedded/reference type: it renders as primary data, linkage and included when it
appears inside another resource, but serves no routes of its own. To give it its own
endpoints you open them explicitly with the operations allow-list, as ChartSerializer
does above — opening exactly GET /charts and GET /charts/{id} and nothing else.
The example app makes the asymmetry observable in
CapabilityCompositionTest:
the charts serializer emits exactly the two named fetch routes…
self::assertArrayHasKey('jsonapi.charts.index', $names); // GET /charts
self::assertArrayHasKey('jsonapi.charts.show', $names); // GET /charts/{id}
self::assertArrayNotHasKey('jsonapi.charts.create', $names);
…while albums (an AbstractResource) emits all five, including the write routes a
standalone serializer omits:
self::assertArrayHasKey('jsonapi.albums.create', $names);
self::assertArrayHasKey('jsonapi.albums.update', $names);
self::assertArrayHasKey('jsonapi.albums.delete', $names);
The allow-list mechanism — the Operation enum, how a declared case becomes a route,
and what happens to a verb you don't expose (the router 404/405s it, no handler is
reached) — belongs to routing. This page owns only the per-capability
defaults; the two pages are cross-linked so they never drift.
Mix-and-match recipes¶
Because every capability is optional and independent, the endpoint set is whatever the capabilities you declare add up to:
| You want | Declare |
|---|---|
| a serialize-only embedded/reference type | a serializer alone (no operations) |
| a read-only fetchable type | a serializer with operations: [FetchCollection, FetchOne] + a provider |
| a write-only ingest endpoint | a hydrator + a persister (no serializer) |
| a fully resource-less CRUD type | serializer + hydrator + relations + provider + persister |
The charts type is the second row, lifted straight from the example. The fourth row
needs no resource at all: the serializer and hydrator (two attributes, possibly on one
class), #[AsJsonApiRelations] if it has relationships, and a provider/persister pair
(data layer) give you the same endpoints an AbstractResource would —
assembled from independent parts instead of one declaration.
Capability override on a resource is the dual of this: an AbstractResource that
keeps the field DSL for most things but swaps one capability for a hand-written class —
#[AsJsonApiResource(serializer: …)] or (hydrator: …). That's owned by
custom-serializers-hydrators; the example's tracks
(serializer override) and playlists (hydrator override) demonstrate it.
The build-time write-capability guard¶
You cannot expose a write without something to populate the entity. If a type's
operations allow-list includes Create or Update but no hydrator is registered for
it, the container fails to compile — ResourceLocatorPass::validateWriteCapability()
throws a \LogicException naming the type and the missing hydrator:
The JSON:API type "charts" exposes a write operation (Create) but has no hydrator;
register #[AsJsonApiHydrator(type: "charts")] or use an AbstractResource.
This is a compile-time fault, not a request-time one — you find it the moment you build
the container, with a fix hint, never as a runtime surprise. (An AbstractResource
always carries a hydrator, so it never trips this.)
Routed operations must have a data layer: the servability guard¶
The composition model is "declare the capabilities you need" — but the operations you
expose are a promise the bundle holds you to. A type that routes a read but has no
DataProvider supporting it, or routes a write with no DataPersister, is not
gracefully degraded into a smaller surface — it is a build-time error. The
non-optional ServableResourceWarmer runs
at cache:warmup and, per (server, type), asserts every exposed operation has the
data layer it needs, throwing a \LogicException that aborts the build:
The JSON:API type "albums" exposes a read operation but no DataProvider supports it.
Map an entity with #[AsJsonApiResource(entity: ...)], register a DataProviderInterface
service for it, or remove its read operations from the allow-list.
The way to not serve a read or a write is therefore to not expose its operation —
trim the operation allow-list (or reach for the
readOnly shorthand to drop
every write at once). Omitting the provider/persister while leaving the operation routed
does not silently disable the endpoint; it fails the deploy. Gating is on the per-type
allow-list, so an embedded-only standalone serializer (no operations) and a
relationship-only target (served through its parent's provider) are correctly not
flagged. This is the same fail-fast posture as the write-capability guard above — the
two together mean a routed operation always has both the capability that shapes it and
the data layer that backs it, checked before any request runs.
How relations differ in wiring¶
Standalone relations resolve identically to resource-declared ones — a resource-less
type that declares #[AsJsonApiRelations] gets the same relationship routes and rendering
as one whose relations live on a resource. The TypeMetadataResolver
sources a type's relations resource-first, then from the registry, so the two are
interchangeable. That is the takeaway; the wiring underneath differs only because of
what the bundle has to store.
The serializer and hydrator attributes record a class-string on their tag, and the
bundle resolves a type to its serializer/hydrator through a class-string service locator
that core can read statically — a serializer's type is a scalar it can ask for without
instantiating anything. Relations can't work that way: a RelationInterface is a
runtime object, not a container-dumpable scalar, so it can't be read at compile time.
So relations resolve through a different path. #[AsJsonApiRelations] services are
collected into a RelationsRegistry keyed by
type, and a type's relations are fetched lazily — the registry calls relations()
on the provider only when a relationship or related endpoint actually needs them:
// src/Server/RelationsRegistry.php
public function relationsFor(string $type): ?array
{
if (!$this->providers->has($type)) {
return null; // no standalone relations for this type
}
$provider = $this->providers->get($type);
\assert($provider instanceof RelationsProviderInterface);
return $provider->relations();
}
The route loader gates relationship routes on a type having relations from either source. The endpoints themselves — what they serve, the per-relation exposure flags — are in relationships.
Next / see also¶
- resources — the
AbstractResourceon-ramp and#[AsJsonApiResource]. - routing — the
Operationallow-list mechanism and how a declared case becomes a route. - custom-serializers-hydrators — overriding one capability on a resource, and
uriType. - data-layer — the provider/persister capabilities that make a type fetchable/writable.
- relationships — what the relations capability's endpoints serve.
- Core capability-composition — the model this page wires into Symfony.