Skip to main content
Sorting is route-level. You declare which fields the endpoint will sort by (and their default direction); clients send ?order_by=... to override it; the framework parses, validates, and populates $request->sorting() for you.

1. Declare sortable fields

use apivalk\apivalk\Router\Route\Route;
use apivalk\apivalk\Router\Route\Sort\Sort;

public static function getRoute(): Route
{
    return Route::get('/api/v1/animals')
        ->sorting([
            Sort::asc('name'),
            Sort::desc('created_at'),
        ]);
}
Sort::asc('name') means: “sortable on name; if the client doesn’t override, default direction is ascending”. Sort::desc(...) is the reverse.

2. Clients pass order_by

The format is a comma-separated list of field names, each optionally prefixed with + (ascending, default) or - (descending):
GET /api/v1/animals                              # default sorting
GET /api/v1/animals?order_by=name                # name ASC
GET /api/v1/animals?order_by=-created_at         # created_at DESC
GET /api/v1/animals?order_by=name,-created_at    # name ASC, created_at DESC
RequestValidationMiddleware rejects anything not declared — ?order_by=hacked_field422. Routes without sorting([...]) skip this check entirely (nothing to validate against).

3. Read the resolved sorts

$request->sorting() returns a SortBag. It implements IteratorAggregate, so iterate directly:
public function __invoke(ApivalkRequestInterface $request): AbstractApivalkResponse
{
    $qb = $this->animals->query();

    foreach ($request->sorting() as $field => $sort) {
        $qb->orderBy($field, $sort->isAsc() ? 'ASC' : 'DESC');
    }

    // ...
}
The bag is always populated — if the client omits order_by, it contains the defaults from Sort::asc(...) / Sort::desc(...) in the order you declared them. Iteration order matches user intent: any sorts the client submitted via ?order_by= come first (in submission order), followed by the route’s defaults for fields the client didn’t specify. So foreach produces the right ORDER BY sequence — primary user choice first, defaults as tiebreakers — without you having to merge anything.

4. Tell user-requested sorts from defaults

Each Sort knows whether it came from ?order_by= or from the route declaration. Branch on isRequested() when you need to:
if ($request->sorting()->created_at->isRequested()) {
    // The client explicitly asked to sort by created_at.
}
To get only the user’s submitted sorts (without route defaults), use getRequested():
foreach ($request->sorting()->getRequested() as $sort) {
    // Empty array if the client did not send order_by at all.
}
has($field) only tells you whether the field is in the bag at all — for declared fields, that’s always true because defaults seed the bag. Use isRequested() to ask “did the user ask for this?”.

Inside a resource

Resources expose availableSortings():
public function availableSortings(): array
{
    return [Sort::asc('name'), Sort::desc('created_at')];
}
AbstractListResourceController reads this and calls $route->sorting([...]) for you. The DocBlock generator also emits typed @method annotations on *ListRequest so $request->sorting()->name autocompletes. See generate OpenAPI + docblocks.

OpenAPI side effects

One query parameter — order_by — is added to the operation with:
  • type: string
  • Description listing supported fields and the + / - syntax.
  • Example matching your declared defaults.

Reference

Full behaviour and edge cases live at HTTP / Sorting.