Resources, discovery & the #[AsJsonApiResource] attribute¶
A JSON:API type — its id, its attributes, its relations — is described by a
resource class extending core's AbstractResource. The core docs own that
vocabulary: see resources
for what AbstractResource is, fields
and field-types
for what goes inside fields(), ids
for the id field, and relations
for the relation DSL.
This page is about the Symfony side: how the bundle discovers your resource,
how the #[AsJsonApiResource] attribute carries the extra metadata Symfony needs,
and the compile-time guards you may hit while wiring one up. Write the resource the
way core teaches; register it as a service the way this page teaches, and you have
the full endpoint set.
Zero-config discovery¶
There is no resource registry to edit and no Server to build by hand. Any service
whose class extends AbstractResource is auto-tagged for the bundle. The
bundle calls registerForAutoconfiguration(AbstractResource::class) and attaches
the public tag haddowg.json_api.resource (the constant
JsonApiBundle::RESOURCE_TAG), so the only thing your app has to do is make the
resource an autoconfigured service.
In the example app that is one stanza — register src/ as autowired +
autoconfigured services and let discovery do the rest
(services.yaml):
services:
_defaults:
autowire: true
autoconfigure: true
haddowg\JsonApiBundle\Examples\MusicCatalog\:
resource: '../src/'
exclude:
- '../src/Entity/'
- '../src/MusicCatalogKernel.php'
Because resources are ordinary services, they can have real constructor
dependencies — the bundle resolves them through the container, not via new.
A minimal fetchable resource looks exactly like its core counterpart — the only
Symfony-specific line is the attribute mapping it to a Doctrine entity
(ArtistResource):
#[AsJsonApiResource(entity: Artist::class)]
final class ArtistResource extends AbstractResource
{
public static string $type = 'artists';
public function fields(): array
{
return [
Id::make(),
Str::make('name')->required()->maxLength(120)->sortable(),
// …
HasMany::make('albums', 'albums'), // lazy by default
];
}
}
Register that, import the routes, and you have GET /artists,
GET /artists/{id}, POST /artists, PATCH /artists/{id},
DELETE /artists/{id}, plus the relationship endpoints for albums.
The all-five default¶
A discovered resource exposes all five CRUD operations by default —
FetchCollection, FetchOne, Create, Update, Delete — and the full set of
relationship endpoints for any relation it declares. You do not opt in to
endpoints; you opt out of the ones you do not want (the operations allow-list,
below, and per-relation exposure, covered in relationships).
This "register → get everything" default is specific to AbstractResource. A
type assembled from a standalone serializer instead defaults to no
operations (serialize-only) — that asymmetry, and the rest of the resource-less
model, lives in capability-composition.
The #[AsJsonApiResource] attribute¶
The attribute is optional — discovery already works without it. You add it to
carry metadata Symfony needs that the class itself cannot express: which Doctrine
entity backs the type, which named server(s) expose it, a per-type serializer or
hydrator override, or an operation allow-list. Its signature (abridged here — the
authz and response-header args are documented separately, below) lives in
AsJsonApiResource:
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AsJsonApiResource
{
public function __construct(
public ?string $type = null,
public string|array|null $server = null,
public ?string $entity = null,
public ?string $serializer = null,
public ?string $hydrator = null,
public array $operations = [],
public bool $readOnly = false,
) {}
}
| Argument | Type | Meaning |
|---|---|---|
type |
?string |
Declaration-site override of the static $type. Only needed in the rare case the wire type differs from the class's $type; normally omitted. |
server |
string \| list<string> \| null |
The named server(s) exposing this type — a single name, a list, or null for the implicit default server (see Server assignment). |
entity |
?class-string |
The Doctrine entity the reference data layer reads and writes for this type. Inert unless doctrine/orm is installed (see doctrine). |
serializer |
?class-string |
A per-type serializer override — a registered service implementing core's SerializerInterface (see custom serializers & hydrators). |
hydrator |
?class-string |
A per-type hydrator override — a registered service implementing core's HydratorInterface. |
operations |
list<Operation> |
The exposed operation allow-list (empty = all five). Mutually exclusive with readOnly. |
readOnly |
bool |
Shorthand for "suppress every write": restricts the type to the two fetch operations. Mutually exclusive with a non-empty operations. |
The constructor also carries the declarative-authorization arguments (
security,securityCreate, …) documented in authorization, the declarative response-header arguments (cacheHeaders,deprecation,sunset,sunsetLink) documented in configuration, and the per-operation response-declaration arguments (create,update,delete,fetchOne,fetchCollection) documented in Per-operation response declarations below — all omitted from the snippet above for brevity.
A second job: the attribute also tags a class that is not an AbstractResource
subclass as a resource. So if you build a type from capabilities rather than the
AbstractResource sugar, the attribute is how you still mark the class — discovery
by base class and discovery by attribute are the two entry points.
Server assignment¶
When your API runs more than one server (an admin surface alongside the public one,
say), server is how a type joins them. The implicit default server needs no
mention; name additional servers explicitly. The example's albums type is the
multi-server witness — exposed on both surfaces
(AlbumResource):
#[AsJsonApiResource(entity: Album::class, server: ['default', 'admin'])]
final class AlbumResource extends AbstractResource
while users is admin-only:
The server names you reference here must be declared under json_api.servers (or
be the literal default); referencing an undeclared server is a build-time
LogicException. Declaring the servers, mounting their routes, and the end-to-end
resolution are covered in configuration,
routing, and multi-server & testing
respectively.
The serializer / hydrator overrides¶
The field DSL cannot always express the wire shape you need, or you want a write to
do something the declarative hydrator cannot. Point serializer/hydrator at a
service and the bundle's single generic handler (the CrudOperationHandler, see
the request lifecycle) drives that type through your class instead of
the field inventory. In the example, tracks overrides its serializer and
playlists its hydrator:
#[AsJsonApiResource(entity: Track::class, serializer: TrackSerializer::class)]
// …
#[AsJsonApiResource(entity: Playlist::class, hydrator: PlaylistHydrator::class)]
Both override services carry real constructor dependencies, bound in
services.yaml — a
successful read/write proves the bundle resolved them through the container rather
than new-ing them. The mechanics, and registering a serializer/hydrator with no
resource at all, are owned by
custom serializers & hydrators.
The operation allow-list¶
operations trims which CRUD endpoints a type serves. It takes a list of
Operation enum cases (FetchCollection,
FetchOne, Create, Update, Delete); an empty list (the default for a
resource) means all five. An unexposed verb is simply unrouted — the router
404s/405s it before any handler runs. The allow-list mechanism — how a case
becomes a route, the per-capability defaults — is owned by routing
and capability-composition; the attribute here is just
where you declare it.
readOnly: the suppress-every-write shorthand¶
The most common trim is "reads only, no writes". operations can spell that out, but
it forces an import and a two-element list:
use haddowg\JsonApiBundle\Operation\Operation;
#[AsJsonApiResource(operations: [Operation::FetchCollection, Operation::FetchOne])]
readOnly: true is the intent-named equivalent — it restricts the type to exactly
those two fetch operations without importing the enum:
operations stays the precise escape hatch for any other subset (a create-only ingest
endpoint, say). The two are mutually exclusive: declaring readOnly: true
and a non-empty operations list is a constructor \LogicException, so an
ambiguous declaration never compiles — pick the shorthand or the explicit list, not
both. A read-only type exposes no writes, so it needs only a
DataProvider (no persister, no hydrator) to be servable.
Per-operation response declarations¶
By default each operation advertises the one success status JSON:API mandates —
POST a 201, PATCH a 200, DELETE a 204, and each read a 200. When an
operation legitimately answers another way — an async 202, a client-generated-id
204 on create, a 303 async-completion redirect — you declare that per operation
on the attribute, and the generated OpenAPI advertises exactly what you serve. Five
arguments, one per operation, each taking a single response object or a list of
them (omit one to keep its default):
| Argument | Operation | Response objects |
|---|---|---|
create |
POST /{type} |
new Created() (201) · new NoContent() (204, client-id) · new Accepted($jobType) (202) |
update |
PATCH /{type}/{id} |
new Ok() (200) · new NoContent() (204) · new Accepted($jobType) (202) |
delete |
DELETE /{type}/{id} |
new NoContent() (204) · new MetaResult() (200, meta-only) |
fetchOne |
GET /{type}/{id} |
new Ok() (200) · new SeeOther() (303, async completion) |
fetchCollection |
GET /{type} |
new Ok() (200) |
The objects live in haddowg\JsonApi\OpenApi\Metadata. Each is valid only on the
operations it belongs to — the argument types enforce that, so new SeeOther() in a
create list is a static-analysis error — and the set is validated at declaration
time (duplicate statuses, more than one 202) via OperationResponses::validate().
new Accepted($jobType) names the job type whose document is the 202 body; it
must be a registered type.
use haddowg\JsonApi\OpenApi\Metadata\{Accepted, MetaResult, NoContent, Ok, SeeOther};
use haddowg\JsonApi\Resource\AbstractResource;
use haddowg\JsonApi\Resource\ResolvesCompletionRedirect;
use haddowg\JsonApiBundle\Attribute\AsJsonApiResource;
use haddowg\JsonApiBundle\Operation\Operation;
// A resource whose create is ALWAYS async; delete may answer 204 or a meta-only 200:
#[AsJsonApiResource(
operations: [Operation::Create, Operation::FetchOne, Operation::FetchCollection, Operation::Delete],
create: [new Accepted('export-jobs')], // POST → 202 only (always deferred)
delete: [new NoContent(), new MetaResult()], // DELETE → 204 or a 200 meta document
)]
final class CatalogExportResource extends AbstractResource { /* … */ }
// The job/status type: its fetch-one redirects to the produced resource when done.
#[AsJsonApiResource(
readOnly: true,
fetchOne: [new Ok(), new SeeOther()], // GET → 200 while running, 303 when complete
)]
final class ExportJobResource extends AbstractResource implements ResolvesCompletionRedirect { /* … */ }
The three async modes fall out of the one argument: sync (omit, or [new Created()]),
always-async ([new Accepted($jobType)] — a 202 only), and maybe-async
([new Created(), new Accepted($jobType)] — the server may commit inline or defer).
The 303 on fetchOne is driven at runtime by a resource (or serializer) implementing
haddowg\JsonApi\Resource\ResolvesCompletionRedirect — see asynchronous
writes, where this catalog-exports / export-jobs pair is the worked
example. The Laravel package projects a byte-identical OpenAPI document for the same
declarations.
One entity, two resource types¶
A resource type and the entity behind it are not one-to-one. The same entity (or
in-memory domain object) can back two or more resource types, each with its own
type, fields, and serializer — the same record presented as different views. The
classic case is a curated public profile alongside a full admin record:
// The full view — admin-only, every field.
#[AsJsonApiResource(entity: User::class, server: 'admin')]
final class UserResource extends AbstractResource
{
public static string $type = 'users';
public function fields(): array
{
return [
Id::make(),
Str::make('displayName')->required(),
Email::make('email')->required(), // private
Date::make('birthDate')->nullable(), // private
// …
];
}
}
// The curated view — same entity, public, display name only.
#[AsJsonApiResource(
entity: User::class,
readOnly: true, // GET only — the shorthand for the two fetch operations
)]
final class PublicProfileResource extends AbstractResource
{
public static string $type = 'public-profiles';
public function fields(): array
{
return [
Id::make(),
Str::make('displayName')->required(),
// email / birthDate / … are simply NOT declared here
];
}
}
Both resources name User::class. The bundle's type → entity map is keyed by
type, so two types mapping to one entity is fine — the compile-time guard only
rejects one type mapping to two different entities (see
doctrine § Activation: the entity map). A type is always supplied by
context: the route resolves it for primary data (/public-profiles/1 is a
public-profiles, /admin/users/1 is a users), and a relation's declaration
resolves it for linkage (below). There is no reverse entity → type map, so the same
User row renders as public-profiles under one route and users under another,
each its own serializer and field inventory.
The curation is the field inventory, not a runtime filter: a field the curated
view does not declare cannot be resurfaced by a sparse fieldset, an ?include, or a
relationship — fields[public-profiles]=email is rejected (the member is unknown to
that type), and email simply never renders. Pair the narrower view with an
operation allow-list (e.g. read-only) and a
server assignment (the public view on the default server, the
full view admin-only) to keep the privileged surface separate.
Sparse-by-default attributes¶
Curation is all-or-nothing per type — a field is either declared (always eligible to
render) or absent. For the middle ground — a field that is part of the type but is
too expensive to render every time — mark it sparseByDefault() (core field DSL):
It is then omitted from the default response and rendered only when the client
explicitly names it in fields[type]:
GET /articles/1 → no relevanceScore
GET /articles/1?fields[articles]=title,relevanceScore → relevanceScore included
Because the field is dropped before its value hook runs, the expensive computation is
skipped on every request that does not ask for it. It stays a fully declared member
(a valid fields[type] name, documented in the schema), so — unlike a curated-out
field — naming it is not rejected. This is the opt-in inverse of the usual sparse
fieldset (present unless excluded), and is orthogonal to hidden() / writeOnly()
(never rendered even when named). Applies to relations too. Witnessed over HTTP in
tests/Functional/SparseByDefaultFieldTest.php (core ADR 0117).
Choosing a relationship's target type¶
A relation declares the resource type it points at as the mandatory second argument
to make(). A monomorphic
relation (one declared type) renders its targets as that type, regardless of what
else the entity could be. So a relation can deliberately point at the curated view
of an entity that is also exposed in full:
// On the playlist: its owner, as the public profile (not the admin `users` type).
BelongsTo::make('publicOwner', 'public-profiles')->storedAs('owner'),
storedAs('owner') reads the same owner association the entity already has, but the
linkage, the relationships/related endpoints, and ?include=publicOwner all render
the owner as public-profiles — the curated view — while a sibling
BelongsTo::make('owner', 'users') off the same column points at the full type.
You choose the view per relation; the declared type wins.
The worked case lives in the example app: PublicProfileResource is the
public-profiles view of the User entity (admin-only UserResource is the full
users view), and PlaylistResource declares publicOwner → public-profiles beside
owner → users — exercised end-to-end in
MultiTypeEntityTest
and as dual-provider conformance in tests/Functional/MultiTypeEntityConformanceTestCase.php.
$type vs $uriType¶
A resource's JSON:API type (the type member in every document) and its URL
segment are separate. $type is mandatory; $uriType (a core static on
AbstractResource, defaulting to $type) lets a book type be served at /books
without changing the document type. Both are read statically during the
compile pass — your resource is never instantiated to discover them — so they must
be static properties, not computed at runtime.
You rarely set $uriType. When you do, the route loader emits the URL with the URI
segment while keeping route names keyed on the JSON:API type; that route-emission
consequence, and the worked book → /books case, are owned by
custom serializers & hydrators.
Self links by convention¶
Two spec-recommended self links render by convention with no configuration:
- Resource self — every resource object (primary data and every
?include'd resource) carriesdata.links.self = {base_uri}/{uriType}/{id}. It uses the URI segment, so abooktype with$uriType = 'books'links to/books/{id}while thetypemember staysbook. It is skipped when the id is empty (a not-yet-persisted echo) or when a hand-writtengetLinks()already supplies aself(which wins). Opt a resource out by overridingemitsSelfLink(): boolto returnfalse— that resource then has nodata.links.self, while the top-level document self is unaffected. - Top-level document self — every data/resource document (single, collection,
related, relationship, meta — but not error documents) carries
links.self= the request URI. On a paginated collection the page's own self (carrying the resolved page params) wins, withfirst/prev/next/lastpreserved alongside.
Both links are storage-agnostic — they derive from the base URI (your configured
base_uri, or the request's own scheme+host when it is empty, the default — see
configuration), the uriType/type, the id and the request URI —
so they are identical on every provider. The behaviour lives in core; the bundle witnesses it
across the dual-provider conformance suites.
Sourcing the resource id¶
Where a new resource's id comes from is governed by two orthogonal axes on the
Id field. By default a POST carrying a client data.id is rejected with a
403, and a POST without one is store-provided — the bundle sets nothing on
the entity and the store/DB assigns the id (a Doctrine #[ORM\GeneratedValue]
column, a database default). This replaces the old "stamp a UUID on every create"
behaviour: a plain Id::make() over an auto-increment entity just works, and the
201 response (and Location) carry the id the database assigned.
Axis 1 — client-id acceptance (default: forbidden):
| Call | Effect |
|---|---|
| (default) | a client data.id is rejected — 403 ClientGeneratedIdNotSupported |
allowClientId() |
a client data.id is optional — used (and format-validated) when supplied, generated otherwise |
requireClientId() |
a client data.id is mandatory — a create without one is 403 ClientGeneratedIdRequired |
Axis 2 — the fallback when the client supplies no id (default: store-provided):
| Call | Effect |
|---|---|
| (default) | store-provided — the bundle sets nothing; the store/DB assigns the id |
generated() |
the bundle mints one from the declared format — uuid() → a v4 UUID, ulid() → a Crockford-base32 ULID (generated() on a non-self-generating format is a build-time \LogicException) |
generateUsing(fn(): string) |
a closure returns the storage key directly (full control; the result is set as-is, never decoded) |
Id::make() // store-provided (DB assigns)
Id::make()->uuid()->generated() // the app mints a v4 UUID
Id::make()->ulid()->generated() // the app mints a ULID
Id::make()->generateUsing(fn() => Id::generateUuid()) // a UUID, no route/format pin
Id::make()->requireClientId() // a natural key the client supplies
Id::make()->uuid()->allowClientId()->generated() // client UUID if given, else minted
Validate the ids you accept.
allowClientId()/requireClientId()without a declared id format (uuid(),ulid(),matchAs('…'), …) accepts any non-empty client-supplied string unvalidated — the same format pins both client-supplied ids and the ids carried in relationship linkage. Declare a format unless free-form natural keys are intentional.Migrating from the old auto-UUID? A non-
GeneratedValuestring-id entity that must keep minting an id needsgenerated()(orgenerateUsing()); otherwise it will persist a blank id. UsegenerateUsing(fn() => Id::generateUuid())rather thanuuid()->generated()when the entity already holds non-UUID ids you must not reject (the format shortcuts also pin the route{id}and add a format constraint).
Id format validation¶
The uuid()/ulid()/numeric()/pattern() shortcuts declare a format constraint
the Symfony Validator bridge enforces in both directions on a
write — before any decode:
- a client-supplied
data.idis validated against the owning resource's id format (a violation is a422at/data/id); and - every relationship linkage id (
{ "type": T, "id": X }) is validated against the related typeT's id format (a violation is a422at/data/relationships/<rel>/data[/<n>]/id). For a polymorphic relation the format is resolved from each linkage's owntype.
This needs symfony/validator installed (the bridge is a suggest dependency); a
type whose id declares no format passes any id.
Encoded resource ids¶
The JSON:API id a client sees need not be the key your entity is stored under.
Attach an encoder to the Id field — Id::make()->encodeUsing($codec) — and the
rendered id (and every link) becomes encode(storageKey), while the entity keeps
holding the real storage key (an integer PK, a binary UUID, …). Id::matchAs($regex)
(or the uuid()/ulid()/numeric()/pattern() shortcuts) constrains the route
{id} so a malformed id 404s at routing. Encoders are user-supplied, and the
decode happens entirely in the reference Doctrine layer (the in-memory provider has
no encoder); the full storage-vs-wire boundary lives in
doctrine § Encoded resource ids.
Compile-time guards¶
The bundle validates your wiring at container build time, not on a request, so
a misconfiguration fails the cache warm-up with a \LogicException that names the
fix rather than surfacing as a confusing 500 later. The ones you may hit from a
resource declaration:
| Failure | Raised when | Fix |
|---|---|---|
| Unregistered override | serializer/hydrator names a class that is not a registered service |
Register the override service so the container can resolve it (with its dependencies). |
| Wrong override type | the override class does not implement core's SerializerInterface / HydratorInterface |
Make the class implement the right core contract. |
| Write without a hydrator | the type exposes Create/Update but has no hydrator |
Add a hydrator (the resource's own, an override, or a standalone #[AsJsonApiHydrator]), or drop the write operations. |
| Unknown server | server references a name not declared under json_api.servers |
Declare the server, or remove the reference. |
| Entity-mapping faults | entity is missing, undeterminable, or two types map to one entity |
See doctrine, which owns the entity-map guards. |
| Read/write without a data layer | a read operation has no DataProvider, or a write has no DataPersister, supporting the type |
Map an entity, register the SPI service, or drop the operation. |
| Missing/duplicate Id | a resource does not declare exactly one Id field |
Add Id::make() (or remove the duplicate). |
| Non-discriminating polymorphic candidate | a polymorphic relation lists a candidate resource that does not override getType() (so it would silently claim siblings' members) |
Override getType() on that resource to discriminate the member by class (e.g. with instanceof). |
The write-without-hydrator guard lives in ResourceLocatorPass::validateWriteCapability();
the override and server guards alongside it in the same pass. The last three run at
cache warm-up in the non-optional ServableResourceWarmer (the provider/persister/Id
and polymorphic-discrimination checks). Doctrine adds its own warm-up guards — sortable /
filterable columns must resolve to a real field/association, and a pivot belongsToMany
must resolve its association entity (see doctrine § Build-time faults).
Because they all run at build time, CI catches them before deployment.
Beyond a resource¶
AbstractResource is the on-ramp, not the only road. You can override a single
capability (a custom serializer or hydrator, above), declare relations
independently of any resource, or skip the resource class entirely and assemble a
type from a serializer + hydrator + relations + provider + persister. That model —
and why a type is really just a bag of independent capabilities — is the subject
of capability-composition.
Next / See also¶
- capability-composition — compose a type from independent capabilities (and the serialize-only default).
- routing — import the routes, the generated route set, and the operation allow-list mechanism.
- doctrine — what the
entitymapping wires up. - configuration — declaring servers and the optional dependencies.
- Core: resources, fields, relations — what goes inside the resource.