Skip to content

Custom serializers, hydrators & handler decoration

The field DSL covers most types: one fields() declaration drives both the read shape and the write shape. But some wire shapes it cannot express — a member computed across two columns, one request member fanned out to several entity properties, a hand-tuned included policy. For those, you hand-write core's SerializerInterface and/or HydratorInterface and tell the bundle to use them instead of the DSL.

This page is the Symfony wiring for four escape hatches: overriding a resource's serializer/hydrator, registering them standalone, customising a type's URL segment (uriType), and decorating the single global operation handler. The serializer and hydrator contracts are core's — link out for what their methods do; everything here is the bundle's attribute arguments, DI requirements, and the route/Location consequences.

Override a resource's serializer or hydrator

A resource keeps its type, its routes, and its registration role, but delegates the wire shape to a hand-written class. You name the override on the attribute:

#[AsJsonApiResource(entity: Track::class, serializer: TrackSerializer::class)]
final class TrackResource extends AbstractResource
{
    public static string $type = 'tracks';
    // … fields() still declares the type, filters, sorts, relations …
}

TrackResource. The serializer: and hydrator: arguments are independent: a resource may override one, the other, or both. TrackResource overrides only its serializer (reads run through TrackSerializer, writes still hydrate from the fields), while PlaylistResource overrides only its hydrator (writes run through PlaylistHydrator, reads still serialize from the fields). The two compose per type — the override is never global.

When you override, the resource's declared fields become inert for that direction: the override owns the I/O, and the generic engine drives reads/writes through it instead of the field inventory. The fields you keep declaring still matter for the other direction and for validationTrackResource still declares fields() so writes validate and hydrate, even though TrackSerializer owns reads.

Why hand-write one

A serializer is the place for a shape the DSL can't compute. TrackSerializer returns getAttributes() as a map of callables, derives displayTitle across trackNumber + title, and adds a nowPlaying member only when the request carries an authenticated user — the attribute set is request-dependent:

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

    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;
}

A hydrator is the place for a fan-out the DSL can't express. PlaylistHydrator's getAttributeHydrator() fills the title column and derives a read-only slug from the same member — "set one column from another", which no field declaration can state:

'title' => static function (mixed $playlist, mixed $value, array $data, string $field) use ($separator): Playlist {
    \assert($playlist instanceof Playlist);
    $title = \is_string($value) ? \trim($value) : '';
    $playlist->title = $title;
    $playlist->slug = self::slugify($title, $separator);

    return $playlist;
},

For what the serializer/hydrator methods must return, read core's serializers and hydrators — the bases (AbstractSerializer, AbstractHydrator), the relationship-rendering trait (RendersRelationsTrait), and the SerializerResolverAwareInterface an override opts into so the registry injects the resolver after construction — implement it only if your serializer renders relationships or included resources (those need the resolver to find sibling serializers); a flat attribute-only serializer does not.

The override must be a registered service

This is the headline Symfony difference from core. Core registers a serializer with a plain new; the bundle resolves the override through the container, so it can take constructor dependencies. TrackSerializer takes a bound $catalogTag, PlaylistHydrator a bound $slugSeparator — a successful read/write proves the bundle resolved each with its dependency, which a plain new could not supply:

final class TrackSerializer extends AbstractSerializer implements SerializerResolverAwareInterface
{
    public function __construct(private readonly string $catalogTag) {}
    // … getMeta() surfaces $this->catalogTag as meta.served_by …
}

Because resolution is by class-string through the container, the override class must be a registered service of the right type. Both are checked at container build time, not request time, by ResourceLocatorPass:

Condition Result
serializer:/hydrator: class is not a registered service (or alias) LogicException — "is not a registered service; register it so it can be resolved"
The class does not implement SerializerInterface / HydratorInterface LogicException — "must implement …"

Standard service autoconfiguration registers both (TrackSerializer/PlaylistHydrator sit under the app's autowired src/), so in practice you just write the class and name it. The CustomSerializerHydratorTest asserts the round trip — meta.served_by: music-catalog on a tracks read, the road-trip-hits slug derived on a playlists write — and that the override stays per-type (a playlists read carries no served_by).

Register a serializer or hydrator standalone

You can register a serializer or hydrator for a type that has no resource at all, with #[AsJsonApiSerializer] / #[AsJsonApiHydrator]. That is the capability-composition model — read that page for the attributes, their tags, and the standalone recipes. Two consequences belong here, where you are choosing between an override and a standalone pair:

  • A standalone serializer is serialize-only by default — it opens no endpoints until its operations allow-list does, whereas an AbstractResource defaults to all five (the default-operations asymmetry; see capability-composition and routing). ChartSerializer opens exactly two: #[AsJsonApiSerializer(type: 'charts', operations: [Operation::FetchCollection, Operation::FetchOne])].
  • A bare serializer/hydrator pair declares no field inventory, so writes through it are not validated (the validator bridge only runs for an AbstractResource-backed type), and a fetch through it gets no field-derived filters/sorts (data layer). If you want validation and query support, keep an AbstractResource and override the direction the DSL can't model — don't drop the resource.

A standalone serializer with no resource has no static $uriType to read, so its URL segment falls back to its getType() unless it implements core's UriTypeAwareInterfaceChartSerializer does, returning 'charts' from uriType().

uriType — a URL segment distinct from the type

A type's JSON:API type member and its URL path segment are separate. By default they are identical; uriType lets them differ — a plural URL for a singular type (/books for type book), or a kebab-cased path. uriType is a core static on AbstractResource (public static string $uriType); this page owns its route/Location consequences in the bundle.

final class BookResource extends AbstractResource
{
    public static string $type = 'book';      // the JSON:API `type` member
    public static string $uriType = 'books';   // the URL segment → /books/{id}
}

The route loader reads $uriType statically from the class-string, exactly as it reads $type — no instantiation. Only the path changes; everything keyed on the type stays the type:

Concern Uses
Route paths (/{seg}, /{seg}/{id}) uriType
Route names (jsonapi.{type}.{action}) the JSON:API type
_jsonapi_type route default + operation dispatch the JSON:API type
The rendered data.type member the JSON:API type
The create Location header + convention self/related links uriType

The Location header on a 201 is built from the segment: $server->baseUri() . '/' . $uriType . '/' . $serializer->getId($entity) (see CrudOperationHandler), resolving uriType via the resource and falling back to the type for a bare serializer/hydrator pair that declares no resource. See routing for the full generated route set.

Docs-only: the music-catalog example app overrides no uriType (core's example domain keeps types and segments identical), so this section is illustrated in prose with a book/books sketch and not witnessed by a CI test. Every other snippet on this page is lifted from the CI-run example app.

Decorate the global handler

A single generic CrudOperationHandler drives every operation for every type over the provider/persister SPI — there is no per-type handler registry. When you need cross-cutting behaviour around operation handling (an audit log on every write, a soft-delete that turns DELETE into an update, a custom envelope), decorate that one service with Symfony's #[AsDecorator]:

#[AsDecorator(CrudOperationHandler::class)]
final class AuditingOperationHandler implements OperationHandlerInterface
{
    public function __construct(
        #[AutowireDecorated] private readonly OperationHandlerInterface $inner,
    ) {}

    public function handle(
        JsonApiOperationInterface $operation,
    ): DataResponse|MetaResponse|RelatedResponse|IdentifierResponse|NoContentResponse|AtomicResultsResponse|ErrorResponse {
        // intercept the operations you care about; delegate the rest:
        if ($operation instanceof CreateResourceOperation) {
            // … audit the write …
        }

        return $this->inner->handle($operation);
    }
}

This works because the ServerFactory injects the handler by service id (service(CrudOperationHandler::class)) and calls withHandler() with it; Symfony's decoration swaps that id transparently, so every server picks up the decorator with no extra wiring.

Reproduce core's OperationHandlerInterface::handle() exactly — a single JsonApiOperationInterface argument and the full return-type union. Copy that union verbatim from OperationHandlerInterface rather than hand-retyping it: the set of response value objects grows as core gains response shapes (the AtomicResultsResponse above arrived with atomic operations), and a decorator that narrows the union — omitting a member core can still return — breaks that operation with a TypeError the moment it fires, even though the rest works. Dispatch on the operation type with instanceof, as CrudOperationHandler does, and delegate everything you do not intercept to the inner handler so every member flows through untouched — see core operations.

Reach for decoration last. Per-type customization should normally compose through the SPIs — a higher-priority DataProvider/DataPersister shadowing the Doctrine fallback for one type — or through the serializer/hydrator overrides above. Decorate the handler only for behaviour that genuinely spans operations or types; for one type's read shape, a serializer override is simpler and more direct.

Next / see also

  • resources#[AsJsonApiResource] and where serializer:/hydrator:/uriType sit on a resource.
  • capability-composition — registering a serializer/hydrator standalone, and the default-operations asymmetry.
  • routing — how uriType and the Operation allow-list shape the generated routes.
  • data-layer — the provider/persister SPI, the preferred customization seam before handler decoration.
  • validation — why a standalone serializer/hydrator pair is not validated.
  • Core serializers · hydrators · capability-composition · operations — the contracts this page wires into Symfony.