Skip to content

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:

#[AsJsonApiResource(entity: User::class, server: 'admin')]

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:

#[AsJsonApiResource(readOnly: true)] // GET /{type} and GET /{type}/{id} only

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):

Integer::make('relevanceScore')->sparseByDefault(),

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.

Two spec-recommended self links render by convention with no configuration:

  • Resource self — every resource object (primary data and every ?include'd resource) carries data.links.self = {base_uri}/{uriType}/{id}. It uses the URI segment, so a book type with $uriType = 'books' links to /books/{id} while the type member stays book. It is skipped when the id is empty (a not-yet-persisted echo) or when a hand-written getLinks() already supplies a self (which wins). Opt a resource out by overriding emitsSelfLink(): bool to return false — that resource then has no data.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, with first/prev/next/last preserved 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-GeneratedValue string-id entity that must keep minting an id needs generated() (or generateUsing()); otherwise it will persist a blank id. Use generateUsing(fn() => Id::generateUuid()) rather than uuid()->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.id is validated against the owning resource's id format (a violation is a 422 at /data/id); and
  • every relationship linkage id ({ "type": T, "id": X }) is validated against the related type T's id format (a violation is a 422 at /data/relationships/<rel>/data[/<n>]/id). For a polymorphic relation the format is resolved from each linkage's own type.

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 entity mapping wires up.
  • configuration — declaring servers and the optional dependencies.
  • Core: resources, fields, relations — what goes inside the resource.