Skip to content

Writing a data-layer adapter

This page is for anyone wiring filters, sorts, and constraints up to a real store. You will learn how the library keeps itself storage-agnostic — it ships typed metadata that describes a query but never runs it — and how to write the handlers and translators that do the running, narrowing the abstract query to your own data layer (a QueryBuilder, a PHP array, a search request).

The core principle: metadata describes, adapters execute

The library draws a deliberate line between metadata and execution. The core ships typed value objects describing intent — constraints (->required(), ->maxLength()), filters (Where, WhereIn), sorts (SortByField) — and a resource class declares which it accepts. But the core never runs any of them against your data. Execution lives in handlers and translators an adapter provides: a filter handler turns a FilterInterface into a query predicate, a sort handler turns a SortInterface into an ordering, a constraint translator turns a ConstraintInterface into your validator's native rule.

The reason for the split is decoupling — the core knows nothing about your storage. There is deliberately no generic Query interface: a handler's query parameter is mixed, so a Doctrine handler narrows it to a QueryBuilder, an in-memory handler to an array, a search adapter to its own request object, and the core couples to none of them.

Note the asymmetry up front: filters and sorts execute through core handler interfaces, but constraints translate through an interface you define — the core ships no ConstraintTranslatorInterface (see Constraints follow the same split below).

The metadata contracts

Each metadata kind is a one-method interface; the concrete value objects add public readonly fields a handler reads.

// Resource\Constraint\ConstraintInterface
public function context(): Context;   // create / update / both

// Resource\Filter\FilterInterface
public function key(): string;        // the filter[<key>] this responds to

// Resource\Sort\SortInterface
public function key(): string;        // the sort key (no leading '-')

A resource declares which it accepts: field constraints inline on each field, filters() for the filter list, and field ->sortable() / sorts() for the sort list. None of those declarations execute on their own — they are inert until an adapter reads them.

The handler contracts

Filters and sorts are executed by handlers, both templated on the query type so no data layer leaks into the core:

/** @template TQuery */
interface FilterHandlerInterface
{
    /** @param TQuery $query @return TQuery */
    public function apply(FilterInterface $filter, mixed $query, mixed $value): mixed;
}

/** @template TQuery */
interface SortHandlerInterface
{
    /** @param list<SortDirective> $sorts @param TQuery $query @return TQuery */
    public function apply(array $sorts, mixed $query): mixed;
}

The two shapes differ for a reason. A filter handler receives one filter, its request-supplied $value, and the query; you call it once per requested filter, folding each predicate onto the query. A sort handler receives the whole ordered list of SortDirectives — most significant first — in a single call, never directive by directive. Sorting does not compose commutatively, and the correct way to combine keys differs per data layer: SQL appends ORDER BY terms in significance order, while an in-memory re-sort must compare keys in one cascading comparator. Handing the handler the full list lets each adapter compose natively and keeps the request's first sort field the primary key everywhere, as the spec requires.

A handler matches on the concrete metadata type and produces the native query operation. When it meets a value object it does not recognise it throwsResource\Filter\UnsupportedFilter or Resource\Sort\UnsupportedSort. Both are server-configuration errors, not client errors, so they render as a 500: a filter or sort was declared (or routed through) with no handler wired to execute it. They are AbstractJsonApiExceptions, so the error-handler middleware renders them as JSON:API error documents automatically, and each exposes the offending VO ($filter / $sort).

The reference handlers

The core ships two worked handlers operating on a PHP list<array|object>: ArrayFilterHandler and ArraySortHandler. They power the package's own integration tests and serve as the canonical example for adapter authors. They are not a production query layer — they filter and sort in memory with no indexing; a real adapter pushes the predicate and the ordering down to its store.

Both read model values through Accessor::get, a framework-agnostic reader that works on an array key, a public property, or a conventional getXxx() / isXxx() accessor — so the same handler reads a plain array row or a domain object.

ArrayFilterHandler — the operator semantics

ArrayFilterHandler::apply builds an array_filter predicate from the matched value object's fields and array_values-reindexes the survivors. The match arm is the reference for what each built-in filter means:

private function predicate(FilterInterface $filter, mixed $value): \Closure
{
    return match (true) {
        $filter instanceof Where => $this->where($filter, $value),
        $filter instanceof WhereIn => $this->whereIn($filter->column, $this->toList($value, $filter->delimiter), false),
        $filter instanceof WhereNotIn => $this->whereIn($filter->column, $this->toList($value, $filter->delimiter), true),
        // … WhereIdIn / WhereIdNotIn / WhereNull / WhereNotNull …
        $filter instanceof WhereHas => fn(mixed $row): bool => $this->hasRelation($row, $filter->relationship),
        $filter instanceof WhereDoesntHave => fn(mixed $row): bool => !$this->hasRelation($row, $filter->relationship),
        default => throw new UnsupportedFilter($filter),
    };
}

The Where arm covers the comparison operators. Most are the obvious PHP comparison; two are worth pinning down:

Where::$operator semantics
=, == loose equality (==)
=== strict equality (===)
!=, <> loose inequality
> >= < <= the ordered comparisons
like case-insensitive ASCII substring (\stripos(...) !== false) — the LIKE '%…%' reference behaviour a SQL adapter should match

Before comparing, Where's optional deserialize closure transforms the incoming value — that is how ->asBoolean() coerces filter[explicit]=true to a real bool. The set filters (WhereIn and friends) split the value into a list with toList(): an array passes through, a string splits on the filter's delimiter (defaulting to a comma) with each element trimmed. WhereHas / WhereDoesntHave are pure existence tests via hasRelation() — a non-empty related collection or a non-null to-one — and ignore the request value entirely.

ArraySortHandler — one cascading comparator

ArraySortHandler::apply honours the single-call ordered-list contract with one usort whose comparator walks the directives in significance order, returning at the first non-zero comparison:

public function apply(array $sorts, mixed $query): mixed
{
    /** @var list<array{string, bool}> $columns */
    $columns = [];
    foreach ($sorts as $directive) {
        $sort = $directive->sort;
        if (!$sort instanceof SortByField) {
            throw new UnsupportedSort($sort);
        }
        $columns[] = [$sort->column, $directive->descending];
    }
    // …
    \usort($query, static function (mixed $a, mixed $b) use ($columns): int {
        foreach ($columns as [$column, $descending]) {
            $cmp = Accessor::get($a, $column) <=> Accessor::get($b, $column);
            if ($cmp !== 0) {
                return $descending ? -$cmp : $cmp;
            }
        }
        return 0;
    });

    return $query;
}

It understands only SortByField — the value object every ->sortable() field auto-derives — and throws UnsupportedSort for anything else, which is exactly the seam a computed sort hooks into (below).

Folding the request over the query

An adapter ties the metadata to the request: read the requested filter[…] keys, match each against the resource's declared filters, and hand the matches to the handler. The worked CriteriaApplier in the music-catalog example does exactly this. Filters first, indexed by key() so a requested parameter finds its declared VO:

/** @var array<string, FilterInterface> $declared */
$declared = [];
foreach ($resource->filters() as $filter) {
    $declared[$filter->key()] ??= $filter;   // first declared wins for a shared key
}

foreach ($requested as $key => $value) {
    $filter = $declared[$key] ?? null;
    if ($filter === null) {
        continue;   // an undeclared filter[…] key is silently ignored
    }
    $rows = $filter instanceof WithinRadius
        ? $this->withinRadius($rows, $filter, $value)   // the custom arm
        : $this->delegateFilter($filter, $rows, $value);
}

Sorts are gathered into the single ordered list the handler contract demands. $resource->allSorts() returns every declared and ->sortable()-derived sort, keyed for lookup; the requested sort=a,-b is split into directives, each carrying its --derived direction:

foreach ($resource->allSorts() as $sort) {
    $allSorts[$sort->key()] = $sort;
}

$directives = [];
foreach ($requested as $entry) {
    $descending = \str_starts_with($entry, '-');
    $key = $descending ? \substr($entry, 1) : $entry;
    if (($sort = $allSorts[$key] ?? null) !== null) {
        $directives[] = new SortDirective($sort, $descending);   // an unknown key is skipped
    }
}

$sorted = $this->sorts->apply($directives, $rows);   // one call, full ordered list

A filter[…] key or sort field that matches no declared metadata is simply skipped — the library never auto-applies anything, so an undeclared parameter cannot narrow or reorder the collection. (See FiltersTest and SortsTest for these run as real requests.)

Extending the vocabulary

Extending the filter or sort vocabulary is one move on both sides of the split: define a custom value object carrying whatever fields the handler needs, list it in the resource, and write the handler arm that understands it — always together, because a handler that meets an unrecognised VO throws Unsupported….

The music catalog ships both. A custom filter, WithinRadius, names the latitude/longitude columns to read — pure metadata, no execution:

final readonly class WithinRadius implements FilterInterface
{
    public function __construct(
        public string $key,
        public string $latColumn,
        public string $lngColumn,
    ) {}

    public function key(): string { return $this->key; }
}

The CriteriaApplier carries the matching execution arm — the same move a Doctrine adapter makes, except it would push a spatial predicate down rather than filtering an array:

$rows = $filter instanceof WithinRadius
    ? $this->withinRadius($rows, $filter, $value)
    : $this->delegateFilter($filter, $rows, $value);

A custom sort, TrackCountSort, orders by a computed trackCount that is not a SortByField. The reference ArraySortHandler understands only SortByField, so the applier executes a computed-sort pre-arm before delegating — the handler never sees it:

foreach ($directives as $directive) {
    if ($directive->sort instanceof TrackCountSort) {
        return $this->sortByTrackCount($rows, $directive->sort, $directive->descending);
    }
}
$sorted = $this->sorts->apply($directives, $rows);   // only SortByField reaches the handler

See filters and sorts for the value-object side of this in full.

Writing a handler for a real store

A production adapter implements the same interfaces, narrowing the query to its own object and matching on the built-in vocabulary:

use haddowg\JsonApi\Resource\Filter\FilterInterface;
use haddowg\JsonApi\Resource\Filter\FilterHandlerInterface;
use haddowg\JsonApi\Resource\Filter\UnsupportedFilter;
use haddowg\JsonApi\Resource\Filter\Where;
use haddowg\JsonApi\Resource\Filter\WhereIn;

/** @implements FilterHandlerInterface<QueryBuilder> */
final class DoctrineFilterHandler implements FilterHandlerInterface
{
    public function apply(FilterInterface $filter, mixed $query, mixed $value): mixed
    {
        \assert($query instanceof QueryBuilder);

        return match (true) {
            $filter instanceof Where => $query->andWhere(/* $filter->column, $filter->operator, $value */),
            $filter instanceof WhereIn => $query->andWhere(/* $filter->column IN (…) */),
            // … the rest of the built-in vocabulary …
            default => throw new UnsupportedFilter($filter),
        };
    }
}

The reference handlers are the behavioural spec to match: a like should be a case-insensitive substring, a set filter should split on the VO's delimiter, and an unrecognised VO must throw the typed Unsupported… rather than silently no-op.

Where the handler runs

The handler is inert until something invokes it for a request. On a bare framework that something is your operation handler: it resolves the resource, reads JsonApiRequestInterface::getFiltering() / getSorting() off the request, matches each requested key against the resource's declared filters/sorts, and calls your handler — exactly the fold the CriteriaApplier performs in Folding the request over the query above. The companion Symfony bundle does this wiring for you.

Constraints follow the same split

Constraints are metadata too, with the same describe/execute separation — but unlike filters and sorts, the core ships one built-in consumer. The JSON Schema compiler translates the structural subset of constraints (Required, Min/Max, Pattern, formats, …) into a per-type schema for request validation. That subset is all the core executes itself.

A framework adapter translates the full constraint set into its native validator rules (Symfony Validator, Laravel rules, …) for complete create/update validation, matching each constraint by its class. There is no core ConstraintTranslatorInterface — the translator contract is the adapter's own; the core's only obligation is to expose the typed constraint VOs off each field.

For a rule the core does not model, define your own ConstraintInterface value object carrying whatever config the rule needs (e.g. a CouponRedeemable with a $campaign property) and attach it to a field with constrain():

public function constrain(ConstraintInterface ...$constraints): static

A custom constraint is not round-tripped to JSON Schema — the schema compiler skips constraints it doesn't recognise, leaving it for your adapter's translator to interpret by matching on its class and reading its typed properties. The same is true of When, which gates a constraint set on a closure the JSON Schema vocabulary cannot express. See validation for the constraint contexts and the structural subset the compiler covers.

ORM-backed adapters live outside the core

The core ships only the in-memory reference handlers; production handlers for an ORM or query builder are a separate concern. A dedicated framework bundle — shipping FilterHandlerInterface / SortHandlerInterface implementations and a constraint translator wired into the request lifecycle — belongs outside this package, so the core stays framework- and storage-agnostic. The companion Symfony bundle is that adapter: a Doctrine ORM data layer composing exactly these contracts.

One execution concern the core does ship, because getting it wrong corrupts pagination rather than merely diverging in style: the cursor (keyset) machinery under Collection\Keyset — see the keyset execution toolkit. An adapter adding cursor pagination consumes KeysetResolver, InMemoryKeyset and CursorTokenMinter rather than reimplementing the column resolution, the NULL=largest order, the staleness check, or the token minting.

Next / see also

  • Filters — the filter value objects and their fields.
  • Sorts — the sort value objects and ->sortable() derivation.
  • Validation — the constraint vocabulary and contexts.
  • Schema validation — the one built-in constraint consumer.
  • Resource classes — declaring filters, sorts, and constraints on a type.
  • Middleware — where Unsupported… errors are rendered.