http clients and apis
Pagination, Filtering, And Versioning
List endpoints need limits and clear rules. Without pagination, a request for /users might try to return every user in the database. Without filtering, clients download more data than they need. Without versioning, changing an API can break existing clients without warning.
These are everyday API design topics. They show up in admin screens, mobile apps, integrations, reporting tools, and background imports.
Pagination
Pagination splits a large result set into smaller pages. Two common styles are page-based pagination and cursor-based pagination.
Page-based pagination uses values such as page=2&per_page=25. It is easy to understand and works well for stable lists.
<?php
declare(strict_types=1);
function paginationFromQuery(array $query): array
{
$page = max(1, (int) ($query['page'] ?? 1));
$perPage = max(1, min(100, (int) ($query['per_page'] ?? 25)));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
];
}
print_r(paginationFromQuery(['page' => '3', 'per_page' => '50']));
print_r(paginationFromQuery(['page' => '-2', 'per_page' => '500']));
// Prints:
// [page] => 3
// [per_page] => 50
// [offset] => 100
// [page] => 1
// [per_page] => 100
// [offset] => 0
Always cap per_page. If clients can request per_page=100000, they can accidentally or deliberately make the endpoint slow.
Cursor pagination
Cursor pagination uses a marker from the previous response, such as cursor=eyJpZCI6MTIzfQ. It is often better for large or frequently changing lists because it avoids expensive high offsets and reduces duplicate or skipped rows while new data is being added.
<?php
declare(strict_types=1);
function nextCursorFromLastItem(?array $lastItem): ?string
{
if ($lastItem === null) {
return null;
}
return base64_encode(json_encode(['id' => $lastItem['id']], JSON_THROW_ON_ERROR));
}
$items = [
['id' => 101, 'name' => 'First'],
['id' => 102, 'name' => 'Second'],
];
echo nextCursorFromLastItem(end($items)) . PHP_EOL;
// Prints:
// eyJpZCI6MTAyfQ==
In production, cursors should be treated as opaque values. Clients should pass them back, not build or edit them.
Response metadata and links
A list response should explain how to get more data. The shape depends on the API, but include enough metadata for clients to navigate.
<?php
declare(strict_types=1);
function listResponse(array $items, array $pagination): array
{
return [
'data' => $items,
'meta' => [
'page' => $pagination['page'],
'per_page' => $pagination['per_page'],
],
'links' => [
'next' => '/v1/users?' . http_build_query([
'page' => $pagination['page'] + 1,
'per_page' => $pagination['per_page'],
]),
],
];
}
print_r(listResponse([['id' => 1]], ['page' => 1, 'per_page' => 25]));
// Prints:
// [links] => [next] => /v1/users?page=2&per_page=25
Avoid making clients guess whether more pages exist if the server already knows.
Filtering
Filtering narrows the result set. Keep filters explicit and validated. Do not pass arbitrary query-string keys directly into SQL or an ORM query.
<?php
declare(strict_types=1);
function userFiltersFromQuery(array $query): array
{
$filters = [];
if (isset($query['status'])) {
$status = (string) $query['status'];
if (!in_array($status, ['active', 'disabled', 'invited'], true)) {
throw new InvalidArgumentException('Invalid status filter.');
}
$filters['status'] = $status;
}
if (isset($query['created_after'])) {
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) $query['created_after']);
if (!$date) {
throw new InvalidArgumentException('created_after must use YYYY-MM-DD.');
}
$filters['created_after'] = $date->format('Y-m-d');
}
return $filters;
}
print_r(userFiltersFromQuery(['status' => 'active', 'created_after' => '2026-01-01']));
// Prints:
// [status] => active
// [created_after] => 2026-01-01
For search-like filters, be clear whether the API does exact matching, partial matching, case-insensitive matching, or full-text search.
Sorting
Sorting belongs near pagination because it controls page stability. If the order is not stable, clients may see duplicate or missing records between pages.
Use an allow-list of sort fields:
<?php
declare(strict_types=1);
function sortFromQuery(array $query): array
{
$allowed = ['created_at', 'email', 'name'];
$sort = (string) ($query['sort'] ?? 'created_at');
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
$field = ltrim($sort, '-');
if (!in_array($field, $allowed, true)) {
throw new InvalidArgumentException('Invalid sort field.');
}
return ['field' => $field, 'direction' => $direction];
}
print_r(sortFromQuery(['sort' => '-created_at']));
// Prints:
// [field] => created_at
// [direction] => desc
Versioning
Versioning gives clients a stable contract while the API evolves. Common approaches include:
- Path versioning:
/v1/users. - Header versioning:
Accept: application/vnd.example.v1+json. - Date-based versions: clients opt into behaviour as of a date.
Path versioning is simple and common. Header and date-based schemes can be cleaner for some platforms, but they require stronger documentation and tooling.
Not every change needs a new version. Adding an optional response field is usually backwards-compatible. Removing a field, changing a field type, renaming a status, or changing error semantics can break clients and should be treated carefully.
What to check
Before moving on, make sure you can:
- Cap page size and calculate an offset.
- Explain when cursor pagination is useful.
- Return list metadata and next links.
- Validate filters and sort fields with allow-lists.
- Identify which API changes are likely to require a new version.
Practice
Practice: Build List Query Rules
Write a PHP function that converts API query parameters into safe list options.
Requirements
- Support
pageandper_page, with sensible defaults. - Cap
per_pageat100. - Support a
statusfilter with an allow-list. - Support
sort, including descending sort with a leading-. - Reject unknown sort fields.
- Return a response shape containing
data,meta, andlinks.next.
Show solution
This solution validates query parameters before they can affect a database query.
<?php
declare(strict_types=1);
function listOptions(array $query): array
{
$page = max(1, (int) ($query['page'] ?? 1));
$perPage = max(1, min(100, (int) ($query['per_page'] ?? 25)));
$filters = [];
if (isset($query['status'])) {
$status = (string) $query['status'];
if (!in_array($status, ['active', 'disabled', 'invited'], true)) {
throw new InvalidArgumentException('Invalid status filter.');
}
$filters['status'] = $status;
}
$sortValue = (string) ($query['sort'] ?? 'created_at');
$direction = str_starts_with($sortValue, '-') ? 'desc' : 'asc';
$field = ltrim($sortValue, '-');
if (!in_array($field, ['created_at', 'email', 'name'], true)) {
throw new InvalidArgumentException('Invalid sort field.');
}
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
'filters' => $filters,
'sort' => ['field' => $field, 'direction' => $direction],
];
}
function userListResponse(array $items, array $options): array
{
$nextQuery = [
'page' => $options['page'] + 1,
'per_page' => $options['per_page'],
];
if (isset($options['filters']['status'])) {
$nextQuery['status'] = $options['filters']['status'];
}
$sortPrefix = $options['sort']['direction'] === 'desc' ? '-' : '';
$nextQuery['sort'] = $sortPrefix . $options['sort']['field'];
return [
'data' => $items,
'meta' => [
'page' => $options['page'],
'per_page' => $options['per_page'],
'sort' => $options['sort'],
'filters' => $options['filters'],
],
'links' => [
'next' => '/v1/users?' . http_build_query($nextQuery),
],
];
}
$options = listOptions([
'page' => '2',
'per_page' => '500',
'status' => 'active',
'sort' => '-created_at',
]);
$response = userListResponse([['id' => 42, 'email' => 'a@example.com']], $options);
echo json_encode($response, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// "page": 2
// "per_page": 100
// "next": "/v1/users?page=3&per_page=100&status=active&sort=-created_at"
The endpoint can now pass $options to a repository or query builder. The important part is that untrusted query-string values have been normalised and checked first.