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_field → 422. 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.