Skip to content

Sorts

A sort declares one key your collection endpoint accepts in the sort query parameter, so clients can order results — GET /tracks?sort=title,-trackNumber. This page covers marking a field sortable, the one built-in sort, writing a computed sort, and the handler contract that turns requested sorts into an ordered query.

Like a filter, a sort in this library is metadata only: a value object that names the sort key and the column it maps to, carrying no behaviour. Turning a sort into an ordered query — an ORDER BY, a usort, a search-engine sort clause — lives in an adapter-provided SortHandlerInterface, not in the sort itself. This keeps core decoupled from any data layer: there is no generic query interface, and the split mirrors exactly how filters and field constraints work.

This page is the declaration side — marking fields sortable and writing sort value objects. If you followed the getting-started handler and just want ?sort=title working end to end, the smallest executing side is the reference ArraySortHandler wired up the way the catalog's CriteriaApplier does it (shown under Executing sorts); see Adapters for pushing the ordering down to a real store.

Marking a field sortable

The common case needs no explicit sort declaration. Calling ->sortable() on a field tells the resource to derive a sort for it automatically. The TrackResource marks two fields sortable:

public function fields(): array
{
    return [
        Id::make(),
        \haddowg\JsonApi\Resource\Field\Str::make('title')->required()->sortable(),
        \haddowg\JsonApi\Resource\Field\Integer::make('trackNumber')->min(1)->sortable(),
        // …
    ];
}

Clients can now request ?sort=title, ?sort=-trackNumber (a leading - means descending), or both at once — ?sort=trackNumber,-title — where the order of the keys is the order of significance: the first is the primary sort, later keys break ties.

GET /tracks?sort=trackNumber,-title

orders primarily by trackNumber ascending, then by title descending within each trackNumber. (SortsTest asserts exactly this ordering.)

allSorts(): derived plus explicit

AbstractResource::allSorts() is the full set of sorts a resource accepts. It walks the fields and produces a SortByField for each one marked ->sortable() (keyed by the field name, targeting the field's column and falling back to the field name), then merges in whatever sorts() returns. The merge is keyed by sort key, later entries win, so an explicit sorts() entry overrides a field-derived one with the same key:

// AbstractResource::allSorts() — derived first, explicit sorts() merged over them
foreach ($this->allFields() as $field) {
    if ($field->isSortable()) {
        $sorts[$field->name()] = SortByField::make($field->name(), $field->column() ?? $field->name());
    }
}
foreach ($this->sorts() as $sort) {
    $sorts[$sort->key()] = $sort;
}

You only ever override sorts() for the non-trivial cases below — for the common field sort, ->sortable() is enough.

SortByField: the one built-in

SortByField is a final readonly value object with a make($key, ?$column) named constructor; the column defaults to the key. You rarely construct it by hand — ->sortable() derives it for you. Declare one explicitly in sorts() only when the sort key is not the field name, or when you want a column override:

use haddowg\JsonApi\Resource\Sort\SortByField;

public function sorts(): array
{
    return [
        SortByField::make('name', 'last_name'), // expose ?sort=name, order by the last_name column
    ];
}

defaultSort(): the order with no ?sort

allSorts() only governs which sorts a collection accepts. With no sort parameter a collection is returned in storage order, which also makes pagination non-deterministic. Override defaultSort() to declare a default order applied only when the request carries no ?sort — an explicit ?sort= overrides it entirely (the default is never appended to a requested sort). The AlbumResource orders albums newest-first by default:

use haddowg\JsonApi\Resource\Sort\SortByField;
use haddowg\JsonApi\Resource\Sort\SortDirective;

public function defaultSort(): array
{
    return [
        new SortDirective(SortByField::make('releasedAt'), descending: true),
    ];
}

GET /albums (no ?sort) now orders by releasedAt descending; GET /albums?sort=title orders by title ascending and ignores the default.

Each entry is a SortDirective — the same SortInterface + direction pair a data layer builds for a requested sort, most significant first — so a default flows through the resource's sort handler on the same path as a requested sort, with no new execution arm. A default must therefore name a sort the handler can execute (a SortByField for the reference ArraySortHandler, or a custom sort with a handler arm). defaultSort() defaults to [] — no default order. The data layer falls back to it whenever the request's parsed sort list is empty:

$requested = $request->getSorting();
if ($requested === []) {
    $directives = $resource->defaultSort(); // [] ⇒ storage order
    // … apply $directives through the same handler a requested sort uses
}

The SortInterface contract

Every sort implements Resource\Sort\SortInterface, whose sole member is the key:

interface SortInterface
{
    public function key(): string; // the sort key, without a leading '-'
}

The -key direction prefix is not part of the key — it is parsed off the request and handed to the handler as a separate descending flag. Concrete sorts add their own public readonly fields (a column, an expression, …) that a handler reads when it orders the query.

Computed and multi-column sorts

For sorts that don't map to a single sortable field — a computed expression, an ordering across columns — write a custom SortInterface and return it from sorts(). The ArtistResource exposes ?sort=trackCount, an ordering by a computed trackCount that has no single backing column:

public function sorts(): array
{
    return [
        new TrackCountSort(),
    ];
}

TrackCountSort is a plain value object carrying whatever its handler needs — here, the key and the property to read:

use haddowg\JsonApi\Resource\Sort\SortInterface;

final readonly class TrackCountSort implements SortInterface
{
    public function __construct(
        public string $key = 'trackCount',
        public string $column = 'trackCount',
    ) {}

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

For it to do anything, a handler must recognise it — see Executing sorts below. A custom sort and the handler arm that understands it are written together.

Executing sorts

The metadata never orders anything on its own. Execution lives in a SortHandlerInterface, and the whole ordered sort is applied in one call:

/** @param list<SortDirective> $sorts */
public function apply(array $sorts, mixed $query): mixed;

This is the key shape: the handler receives the full ordered list of directives — most significant first — not one directive at a time. 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 a single 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.

Each element is a SortDirective — a final readonly pair of the matched sort and its direction:

final readonly class SortDirective
{
    public function __construct(
        public SortInterface $sort,
        public bool $descending,
    ) {}
}

Reading the requested sorts

The requested sorts reach you through the request's parsed sort list. From an operation handler read it via queryParameters()->sort (a list<string> with the leading - preserved), or directly from the request with JsonApiRequestInterface::getSorting(). You match each requested key against the type's allSorts(), build a SortDirective per match, and hand the whole list to the handler. The catalog's CriteriaApplier does exactly this:

$requested = $request->getSorting();

/** @var array<string, SortInterface> $allSorts */
$allSorts = [];
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;

    $sort = $allSorts[$key] ?? null;
    if ($sort === null) {
        continue; // an unknown sort key is skipped, not an error
    }

    $directives[] = new SortDirective($sort, $descending);
}

$sorted = $this->sorts->apply($directives, $rows);

A sort key matching no declared or derived sort is ignored — the applier skips it and leaves the order untouched, rather than rejecting the request.

That silent-ignore is key-level — an unrecognized key inside the sort family. The sort family itself is always recognized, so it never trips the strict query-parameter validation Server runs by default: an unrecognized query-parameter family (a misspelled ?srot=..., an unregistered custom parameter) is a 400 QueryParamUnrecognized, distinct from this key-level tolerance within a recognized family.

The reference ArraySortHandler

Core ships Resource\Sort\InMemory\ArraySortHandler as a reference handler that orders a PHP list<array|object>. It collects the directives' columns and runs one usort whose comparator cascades through them in significance order, so the first sort field stays primary:

public function apply(array $sorts, mixed $query): mixed
{
    $columns = [];
    foreach ($sorts as $directive) {
        $sort = $directive->sort;
        if (!$sort instanceof SortByField) {
            throw new UnsupportedSort($sort);
        }
        $columns[] = [$sort->column, $directive->descending];
    }
    // … one usort, comparing each column in turn until one differs
}

It is used by the package's own tests and serves as the canonical adapter example — it is not a production sort layer. A real adapter pushes the ordering down to its data store (see Adapters). Note that it understands only SortByField: a custom sort like TrackCountSort would throw UnsupportedSort if it reached the handler, so the CriteriaApplier executes the computed sort in a pre-arm before delegating the field sorts:

// The reference ArraySortHandler only understands SortByField, so a computed
// sort is executed here before the field sorts are delegated.
foreach ($directives as $directive) {
    if ($directive->sort instanceof TrackCountSort) {
        return $this->sortByTrackCount($rows, $directive->sort, $directive->descending);
    }
}

Unsupported sorts

If a sort value object reaches a handler that does not recognise it, Resource\Sort\UnsupportedSort is thrown. Like UnsupportedFilter, it is a server configuration error, not a client error, so it renders as a 500 — a sort was declared (or routed through) with no handler wired to execute it. It is an AbstractJsonApiException like the rest of the exception hierarchy, so the error-handler middleware turns it into a JSON:API error document automatically, and it exposes the offending sort via its public $sort property.

This is distinct from SortParamUnrecognized, which is a client 400 for a sort parameter the server rejects outright; UnsupportedSort is purely about a handler gap on the server side.

Next / see also

  • Fields->sortable() and the field DSL.
  • Adapters — the handler side: ordering a real data store.
  • Filters — the same metadata/handler split for the filter parameter.
  • Pagination — windowing the ordered collection.
  • Resource classessorts(), allSorts(), and the derivation rule.
  • Relations — scoping sorts (and filters) to a related to-many collection with withSorts() / withFilters().