Skip to content

Capability composition

A JSON:API type is assembled from independent, optional capabilities — a serializer (read), a hydrator (write), relations, a data provider (fetch), and a data persister (write). AbstractResource is convenient sugar that bundles serializer + hydrator + relations + the fields DSL into one declaration, but nothing is coupled to it. This page shows the seams; the underlying model is core's (bundle ADR 0024, ported here as ADR 0011).

The default: one resource, everything

Most types are a single AbstractResource. It supplies the read shape, the write shape, the relations, and (via the Eloquent layer) fetch + persist by convention. If you need no writes, declare readOnly: true and no persister is used; if you need no reads at all, a resource is the wrong tool — use a standalone serializer.

A standalone serializer (no resource)

A type whose wire shape is fully hand-written, or that has no model at all, is registered with #[AsJsonApiSerializer] on a class implementing core's SerializerInterface. It is serialize-only by default — it renders as primary data, linkage and included, but exposes no endpoints unless you allow-list operations:

use haddowg\JsonApiLaravel\Attribute\AsJsonApiSerializer;
use haddowg\JsonApiLaravel\Operation\Operation;
use haddowg\JsonApi\Serializer\SerializerInterface;

#[AsJsonApiSerializer(
    type: 'charts',
    operations: [Operation::FetchCollection, Operation::FetchOne],
)]
final class ChartSerializer implements SerializerInterface
{
    // getType(), getId(), getAttributes(), … — core's SerializerInterface
}

The example's charts and countries are exactly this: a standalone serializer plus a custom data provider (a ChartProvider over a fixed list; a CountryProvider sourcing rows from symfony/intl). Neither has a model — the capability-composition witness.

#[AsJsonApiSerializer] takes type, operations (empty = serialize-only), server, and tags.

A standalone hydrator (no resource)

The write half is registered the same way: #[AsJsonApiHydrator] on a class implementing core's HydratorInterface registers the type's hydrator with no resource. A standalone type is writable exactly when a hydrator is registered for it (and a persister is wired) — no hydrator means no writes:

use haddowg\JsonApiLaravel\Attribute\AsJsonApiHydrator;
use haddowg\JsonApi\Hydrator\HydratorInterface;
use haddowg\JsonApi\Request\JsonApiRequestInterface;

#[AsJsonApiHydrator(type: 'beacons')]
final class BeaconHydrator implements HydratorInterface
{
    public function hydrate(JsonApiRequestInterface $request, mixed $domainObject): mixed
    {
        // read the request document's attributes onto the domain object
        $label = $request->getResourceAttribute('label');
        // … assign, then:
        return $domainObject;
    }
}

#[AsJsonApiHydrator] takes type and server — deliberately no operations: a hydrator adds write capability, never endpoints. Endpoints are opened only by the paired serializer's allow-list (or a resource's), so a hydrator registered alone exposes nothing — it is the write shape core resolves through hydratorFor(), e.g. for a custom action's decoupled inputType document.

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
standalone #[AsJsonApiHydrator] none — a hydrator never opens endpoints

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 endpoints you open them explicitly with the operations allow-list — the example's charts opens exactly GET /charts and GET /charts/{id}; a hydrator-paired type may open the write verbs the same way (CreatePOST, UpdatePATCH, DeleteDELETE).

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 shape with no endpoints (e.g. an action inputType) a hydrator alone
a fully resource-less CRUD type serializer (all five operations) + hydrator + 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) plus a provider/persister pair give you the same CRUD endpoints an AbstractResource would — assembled from independent parts instead of one declaration. Relations stay resource-only: a resource-less type declares none, so it never gets the related/relationship routes.

The write-capability guard

You cannot expose a write without something to populate the entity. If a standalone type's operations allow-list includes Create or Update but no hydrator is registered for it, route registration fails with a LogicException naming the type and the fix:

The JSON:API type "charts" exposes a write operation (Create) but has no hydrator;
register #[AsJsonApiHydrator(type: "charts")] or use an AbstractResource.

This surfaces at boot (and at route:cache / jsonapi:optimize — a deploy step), never as a runtime surprise on the first request. Delete hydrates nothing, so it needs no hydrator — only a persister, which the servability guard holds it to. (An AbstractResource always carries a hydrator, so it never trips this.)

Dependency injection

Every capability class is constructed through the container, so it can take constructor dependencies — a repository, a config value, a service. Bind a scalar constructor argument the usual Laravel way:

// In a service provider's register():
$this->app->when(ChartSerializer::class)
    ->needs('$catalogTag')
    ->give('music-catalog');

Discovery reads ::$type statically at scan time and constructs the class lazily on first use, so injected dependencies never run during discovery or route:cache.

Customising the read shape without a separate class

Because AbstractResource is the serializer, you shape a read by overriding its methods or by per-field closures — no separate serializer needed for most cases:

Str::make('displayTitle')->computed()->readOnly()
    ->extractUsing(static fn(mixed $t): string => Accessor::get($t, 'track_number') . '. ' . Accessor::get($t, 'title'));

Map::make('releaseInfo')->nullable()
    ->serializeUsing(static fn(mixed $m) => Accessor::get($m, 'release_info') ?: null)
    ->fillUsing(static fn(mixed $m, mixed $v) => /* … write back … */ $m);

See custom-serializers-hydrators for the write side and for handing one concern to a dedicated class with the per-resource #[AsJsonApiResource(serializer: …, hydrator: …)] override.

One model, two types

Two resource types may back the same model with different projections. The example maps both the admin-only users and the public public-profiles to the User model:

#[AsJsonApiResource(server: 'admin', model: User::class)]
final class UserResource extends AbstractResource
{
    public static string $type = 'users';
    // email, password (write-only), preferences, … — the full projection
}

#[AsJsonApiResource(operations: [Operation::FetchCollection, Operation::FetchOne], model: User::class)]
final class PublicProfileResource extends AbstractResource
{
    public static string $type = 'public-profiles';
    // displayName only — the private columns are simply never declared
}

The curation is the field inventory: a narrower type cannot leak a wider type's columns because it never declares them. This is the storage-agnostic way to do "proxy resources" — no new machinery. (The package forbids one type mapping to two models, not two types to one model.) Note the model: declarations: the convention guess maps usersUser by name, but could never guess that public-profiles shares the same model — a shared or diverging model is declared per type, or covered by the explicit map, which shadows both.

Registering providers and persisters

The fetch/persist capabilities are registered by priority — higher wins the first supports() match. The auto-registered reference Eloquent pair sits at -256 (the model-map tiers); a hand-wired reference pair conventionally registers at -128; any application provider (default priority 0) shadows both for the types it serves:

JsonApi::provider(new ChartProvider());                 // priority 0 — shadows Eloquent
JsonApi::provider($eloquentProvider, priority: -128);   // the hand-wired reference fallback
JsonApi::persister($eloquentPersister, priority: -128); // (the auto pair sits below, at -256)

The SPI itself is on custom-data-providers.