Skip to content

Serializers

A custom serializer gives you full control over how a domain object becomes a JSON:API resource — for the read-side cases a Resource class's field declaration can't express. You implement Serializer\SerializerInterface directly (or extend Serializer\AbstractSerializer), then either register it as an override on a type — replacing the Resource class's serialization while keeping its hydration — or register it standalone under a type string with no Resource at all. This page also covers the read side of polymorphism: rendering a mixed-type collection through PolymorphicSerializer.

For the common case you never write one — a Resource class's fields() serializes for you — so the escape hatches come in tiers, cheapest first.

A note on names. "Resource" is overloaded. The class documented here is a serializerSerializer\SerializerInterface, the lower-level serializer contract. It is not the JSON:API spec's resource object (the {type, id, attributes, relationships} structure inside data), which this package emits as a plain array rather than a class you write. It is also not the Resource class (Resource\AbstractResource), the primary surface a custom serializer gives you a way around. See Concepts.

The escape-hatch tiers

Reach for the cheapest tier that covers your case:

  1. A single custom field — keep the Resource class and attach a serializeUsing() / extractUsing() hook to just that field. Use this whenever one member needs custom read logic.
  2. A whole custom serializer — replace the type's serialization entirely. Reach for this last, only when serialization needs logic a field walk can't model.

When to write a whole serializer

Three triggers justify a full serializer over a field hook:

  • Request-aware or conditional attributes — a member that appears, changes shape, or is computed differently depending on the current request. The serializer receives the JsonApiRequestInterface for every attribute, so even the set of attributes can depend on the request.
  • Computed or derived values that draw on several model members at once, or on data outside the model.
  • Multiple representations of one model — the same domain object exposed as more than one resource type, registered under different serializers.

The contract

SerializerInterface maps a domain value (mixed — an object, an array, or any representation) to the parts of a JSON:API resource object. There are seven methods:

Method Signature Returns
getType getType(mixed $object): string the resource type
getId getId(mixed $object): string the resource id
getMeta getMeta(mixed $object, JsonApiRequestInterface $request): array top-level meta for the resource
getLinks getLinks(mixed $object, JsonApiRequestInterface $request): ?ResourceLinks the resource links (or null)
getAttributes getAttributes(mixed $object, JsonApiRequestInterface $request): array a map of callables, one per attribute
getDefaultIncludedRelationships getDefaultIncludedRelationships(mixed $object): array list<string> of relations to include by default
getRelationships getRelationships(mixed $object, JsonApiRequestInterface $request): array a map of callables, one per relationship
interface SerializerInterface
{
    public function getType(mixed $object): string;
    public function getId(mixed $object): string;

    /** @return array<string, mixed> */
    public function getMeta(mixed $object, JsonApiRequestInterface $request): array;

    public function getLinks(mixed $object, JsonApiRequestInterface $request): ?ResourceLinks;

    /** @return array<string, callable(mixed, JsonApiRequestInterface, string): mixed> */
    public function getAttributes(mixed $object, JsonApiRequestInterface $request): array;

    /** @return list<string> */
    public function getDefaultIncludedRelationships(mixed $object): array;

    /** @return array<string, callable(mixed, JsonApiRequestInterface, string): AbstractRelationship> */
    public function getRelationships(mixed $object, JsonApiRequestInterface $request): array;
}

Statelessness and maps of callables

The serializer is stateless: every method is a pure function of its arguments, so a single instance safely serializes many objects — collection items and recursively included resources alike. A resource's identity (getType() / getId()) and its default includes depend only on the object; the request-shaped members (getMeta() / getLinks() / getAttributes() / getRelationships()) receive the request directly.

getAttributes() and getRelationships() return maps of callables, not values: each callable receives the domain object, the request, and the member name, and returns the value (or, for a relationship, an AbstractRelationship). The engine invokes only the callables for members that survive sparse-fieldset filtering — declaring an expensive attribute costs nothing unless the client actually asks for it. The request is passed to getAttributes() / getRelationships() themselves as well, so the set of members — not just each value — can depend on the request.

A worked example

TrackSerializer is registered as a read override for the tracks type. It exercises two of the three triggers: a request-aware nowPlaying (present only when the request carries an authenticated user) and a computed displayTitle assembled across two columns on read.

use haddowg\JsonApi\Request\JsonApiRequestInterface;
use haddowg\JsonApi\Serializer\AbstractSerializer;

// SerializerResolverAwareInterface is the opt-in for relationship rendering,
// covered in "Rendering relationships from a serializer" below; it is not needed
// for the request-aware/computed attributes this example is about.
final class TrackSerializer extends AbstractSerializer implements SerializerResolverAwareInterface
{
    public function getType(mixed $object): string
    {
        // Object-aware so a polymorphic resolver probing this serializer does not
        // falsely claim a foreign member as a `tracks` resource.
        return $object instanceof Track ? 'tracks' : '';
    }

    public function getId(mixed $object): string
    {
        \assert($object instanceof Track);

        return $object->id;
    }

    public function getAttributes(mixed $object, JsonApiRequestInterface $request): array
    {
        $attributes = [
            'title' => static fn(mixed $track, JsonApiRequestInterface $request, string $field): string
                => $track instanceof Track ? $track->title : '',
            // …
            // Computed across two columns purely on read.
            'displayTitle' => static fn(mixed $track, JsonApiRequestInterface $request, string $field): string
                => $track instanceof Track ? \sprintf('%d. %s', $track->trackNumber, $track->title) : '',
        ];

        // Request-aware: `nowPlaying` exists ONLY for an authenticated user. The
        // attribute *set* is request-dependent — anonymous responses omit it.
        if ($request->getAttribute('user') !== null) {
            $attributes['nowPlaying'] = static fn(mixed $track, JsonApiRequestInterface $request, string $field): bool
                => $track instanceof Track && $request->getAttribute('nowPlayingTrackId') === $track->id;
        }

        return $attributes;
    }

    // …
}

Notice the callables guard their input with $track instanceof Track and getType() returns '' for a non-Track: the serializer keeps no per-pass state, and a polymorphic resolver may probe it with a foreign object. Returning '' from getType() lets such a probe fall through to the right serializer rather than falsely claiming the member.

AbstractSerializer and TransformerTrait

AbstractSerializer is SerializerInterface plus the Serializer\TransformerTrait and nothing else — the contract is stateless, so there is no per-pass machinery to inherit. The trait gives you attribute-value formatting helpers:

Method Use
toDecimal(mixed $value, int $precision = 12): float normalise a numeric value to a fixed precision
toIso8601Date(\DateTimeInterface $dt, ?\DateTimeZone $tz = null): string a \DateTimeInterface to an ISO-8601 date
toIso8601DateTime(\DateTimeInterface $dt, ?\DateTimeZone $tz = null): string a \DateTimeInterface to an ISO-8601 date-time
fromSqlToIso8601Time(string $value, ?\DateTimeZone $tz = null): string a SQL timestamp string to ISO-8601
fromSqlToUtcIso8601Time(string $value): string a SQL timestamp string to UTC ISO-8601

The trait is public and independently composable: if you implement SerializerInterface directly you can use TransformerTrait on your own class rather than extend the base.

Registering an override

Register the serializer alongside the Resource class with the serializer: argument. The registry resolves the override ahead of the Resource class for serialization and falls back to the Resource class for hydration, so you keep the Resource class's field-driven writes — read and write are independently resolvable. From bootstrap.php:

$server = Server::make()
    // …
    ->register(TrackResource::class, serializer: TrackSerializer::class);

Override serializers take no constructor arguments. The registry instantiates an override with new TrackSerializer(). By default it does not inject the relationship SerializerResolverInterface, so an override is best suited to shaping attributes (request-aware, conditional, computed). When a type needs both related-resource serialization and attribute logic the field walk can't express, keep the Resource class and override only the narrower concern — or opt in to the resolver (below).

For the full picture of override resolution and bare registration, see capability composition.

Rendering relationships from a serializer

An override or standalone serializer renders relationships only if it accepts the injected SerializerResolverInterface. Implement SerializerResolverAwareInterface and the registry calls setSerializerResolver() after construction; without it, getRelationships() has nothing to resolve related types against and returns [].

TrackSerializer opts in and renders the same album and playlists relations the Resource declares, through the shared RendersRelationsTrait. The trait supplies one helper — relationshipCallables(array $relations, SerializerResolverInterface $resolver): array — which turns a list of relation fields into the callable map getRelationships() returns. It does not supply the relations themselves: the relations() it is handed is a method you write on the serializer, returning a list<RelationInterface> (in TrackSerializer it re-declares the same album / playlists the Resource declares). It is built per call, since the contract is stateless:

use haddowg\JsonApi\Resource\RendersRelationsTrait;
use haddowg\JsonApi\Resource\SerializerResolverAwareInterface;
use haddowg\JsonApi\Resource\SerializerResolverInterface;

final class TrackSerializer extends AbstractSerializer implements SerializerResolverAwareInterface
{
    use RendersRelationsTrait;

    private ?SerializerResolverInterface $serializerResolver = null;

    public function setSerializerResolver(SerializerResolverInterface $resolver): void
    {
        $this->serializerResolver = $resolver;
    }

    public function getRelationships(mixed $object, JsonApiRequestInterface $request): array
    {
        $resolver = $this->serializerResolver;
        if ($resolver === null) {
            return [];
        }

        return self::relationshipCallables($this->relations(), $resolver);
    }

    // …
}

getDefaultIncludedRelationships() returns the relations to include by default (['album'] would default-include the album); the default-include lever lives on the serializer contract, not on a fluent field method. See sparse fieldsets & includes for how includes and default-includes flow.

A standalone read-only serializer

A serializer can stand alone with no Resource and no hydrator — a read-only type. ChartSerializer is registered by type string, not class:

$server = Server::make()
    // …
    ->registerSerializerHydrator('charts', serializer: ChartSerializer::class);

Because nothing else is registered for charts, hasSerializerFor('charts') is true but hasHydratorFor('charts') is false, and resourceFor('charts') throws NoResourceRegistered — the read/write resolver mirror and the boundary that proves read and write are decoupled. See capability composition for the full standalone story.

ChartSerializer has no relations, so its getRelationships() returns []. A standalone serializer that does render relations declares them exactly as the override above does — there is no Resource to lean on, but none is needed: the relations() list is hand-written on the serializer itself. Implement SerializerResolverAwareInterface, use RendersRelationsTrait, and return relationshipCallables($this->relations(), $resolver) from getRelationships(), with your own relations() supplying the list<RelationInterface>.

A standalone serializer can also declare its own URL segment by implementing UriTypeAwareInterface:

use haddowg\JsonApi\Serializer\UriTypeAwareInterface;

final class ChartSerializer extends AbstractSerializer implements UriTypeAwareInterface
{
    public function uriType(): string
    {
        return 'charts';
    }

    // …
}

This is the same URL-segment decoupling already taught for AbstractResource's $uriType (see resources), exposed here as an interface a standalone serializer implements directly. uriType() is the URI path segment, decoupled from the JSON:API type member: a type whose getType() is book can live at /books. A serializer that does not implement the interface falls back to getType() as the segment.

Polymorphic serialization (the read side of polymorphism)

A polymorphic relation — MorphTo (to-one) or MorphToMany (to-many) — points at members of different types. On read, each member must be rendered by its own serializer. PolymorphicSerializer is the decorator that makes this work: it wraps a closure that resolves the right serializer for a given member object and delegates every one of the seven methods to it, so each member carries its own correct type / id / attributes.

final class PolymorphicSerializer implements SerializerInterface
{
    /** @param \Closure(mixed): SerializerInterface $serializerFor */
    public function __construct(private readonly \Closure $serializerFor) {}

    public function getType(mixed $object): string
    {
        return $this->for($object)->getType($object);
    }

    // … every method delegates to $this->for($object) …

    private function for(mixed $object): SerializerInterface
    {
        return ($this->serializerFor)($object);
    }
}

The per-member resolution typically runs through RelationInterface::resolveSerializer(), which picks the declared type whose serializer reports the object's own getType():

public function resolveSerializer(mixed $related, SerializerResolverInterface $resolver): ?SerializerInterface;

For a monomorphic relation it returns the single declared type's serializer; for a MorphTo it discriminates by the object's type; for a null related value it returns the first declared, registered serializer (the caller renders data: null). It returns null when the relation declares no registered type, or — polymorphic — when no declared type matches the object.

Worked: favoritable (MorphTo) and items (MorphToMany)

The FavoriteResource declares a MorphTo to-one — favoritable points at a track, album, or artist:

MorphTo::make('favoritable', ['tracks', 'albums', 'artists'])
    ->extractUsing(static fn(mixed $favorite): ?object => $favorite instanceof Favorite ? $favorite->favoritable : null),

GET /favorites/1/favoritable resolves the member's serializer from the related object's own type at runtime, so the same endpoint shape renders a tracks, albums, or artists resource depending on the favorite — verified in PolymorphicTest.

The LibraryResource declares a MorphToMany to-many — items is a mixed collection rendered through PolymorphicSerializer:

MorphToMany::make('items', ['tracks', 'albums', 'artists']),

GET /libraries/1/items returns tracks, albums, and artists in one collection, each member carrying its own type and attributes. A member matching no declared type throws a \LogicException. The polymorphic to-many carries no shared filter/sort vocabulary, so ?filter[…] and ?sort=… return 400 (see errors & exceptions), but ?page[…] slices the mixed collection. The relationship endpoint GET /libraries/1/relationships/items renders the mixed linkage (type + id only, no attributes).

See related endpoints for the endpoint-level behaviour of these relations.

Next / See also

  • Hydrators — the matching write-side customisation point, and relationship-write hydration.
  • Sparse fieldsets & includes — sparse fieldsets and include, which the maps-of-callables design serves.
  • Capability composition — override resolution and standalone serializer/hydrator registration in full.
  • Relations — declaring MorphTo / MorphToMany and the rest of the relation vocabulary.
  • Resources — the field DSL a custom serializer gives you a way around.