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.
Offset Pagination Has Real Limits
Offset pagination is easy to implement with SQL LIMIT and OFFSET, but large offsets become expensive because the database still has to walk past skipped rows. It can also drift when rows are inserted or deleted while a client moves through pages.
For a back-office screen with stable filters and a small result set, page-based pagination is often fine. For public feeds, event logs, audit tables, high-write systems, or long exports, cursor pagination is usually safer.
Do not expose unlimited page numbers merely because the UI has a page input. Set a maximum per_page, consider a maximum page or offset, and return an error or guidance for deeper exports. If a client needs all data, a background export API may be more appropriate than asking for page 20,000.
Stable Ordering Is Mandatory
Pagination without deterministic ordering is broken. If several rows share the same created_at value and the query sorts only by created_at, the database may return ties in different orders between requests. Clients can see duplicate or missing rows.
Add a unique tie-breaker to the sort, usually the primary key:
<?php
declare(strict_types=1);
function orderByForList(string $sortField, string $direction): array
{
$allowed = ['created_at', 'email', 'name'];
if (!in_array($sortField, $allowed, true)) {
throw new InvalidArgumentException('Invalid sort field.');
}
return [
[$sortField, $direction],
['id', $direction],
];
}
print_r(orderByForList('created_at', 'desc'));
// Prints:
// [0] => [0] => created_at [1] => desc
// [1] => [0] => id [1] => desc
The exact SQL depends on the database and query builder. The API contract should document the visible sort while the server enforces stable ordering internally.
Cursor Pagination Needs Enough State
A cursor usually stores the last item's sort values, not just its ID. If the list is ordered by created_at desc, id desc, the next-page query needs both values to continue after the last item.
The cursor should be opaque to clients. Base64-encoded JSON is readable and useful for examples, but production APIs may sign or encrypt cursors to prevent tampering. At minimum, validate every decoded cursor and reject one that does not match the current endpoint, filters, and sort.
<?php
declare(strict_types=1);
function encodeCursor(string $createdAt, int $id): string
{
return rtrim(strtr(base64_encode(json_encode([
'created_at' => $createdAt,
'id' => $id,
], JSON_THROW_ON_ERROR)), '+/', '-_'), '=');
}
function decodeCursor(string $cursor): array
{
$json = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($json === false) {
throw new InvalidArgumentException('Cursor is not valid base64.');
}
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data) || !isset($data['created_at'], $data['id'])) {
throw new InvalidArgumentException('Cursor has an invalid shape.');
}
return $data;
}
$cursor = encodeCursor('2026-06-10T09:30:00Z', 102);
print_r(decodeCursor($cursor));
Do not let clients combine an old cursor with new filters or a different sort unless the cursor design supports that. The simplest rule is that cursor replaces page navigation but the original filter and sort parameters must remain unchanged.
Preserve Query Parameters In Links
A next link should carry the active filters, sort, and page size. Dropping one parameter changes the result set mid-navigation.
<?php
declare(strict_types=1);
function nextLink(array $query, string $cursor): string
{
unset($query['page']);
$query['cursor'] = $cursor;
return '/v1/users?' . http_build_query($query);
}
echo nextLink([
'status' => 'active',
'sort' => '-created_at',
'per_page' => '50',
], 'abc123') . PHP_EOL;
// Prints:
// /v1/users?status=active&sort=-created_at&per_page=50&cursor=abc123
Response links may be absolute URLs or documented relative URLs. Be consistent. If an API sits behind a proxy, build absolute URLs from trusted configuration rather than blindly trusting the incoming Host header.
Filters Need Documented Semantics
Every filter should define its matching behavior. For example, status=active is an exact enum match. created_after=2026-01-01 may mean greater than or equal to midnight UTC, or it may be interpreted in a user timezone. q=ada might search email, name, or both.
Do not accept arbitrary query keys and turn them into column names. Use an allow-list and convert input into a query specification the repository understands.
<?php
declare(strict_types=1);
function normaliseBooleanFilter(mixed $value): bool
{
return match ($value) {
'true', '1', 1, true => true,
'false', '0', 0, false => false,
default => throw new InvalidArgumentException('Boolean filter must be true or false.'),
};
}
var_dump(normaliseBooleanFilter('true'));
var_dump(normaliseBooleanFilter('0'));
// Prints:
// bool(true)
// bool(false)
Document whether repeated parameters such as status=active&status=invited are allowed. PHP's default query parsing can overwrite repeated keys unless array syntax such as status[]=active is used. API clients should not need to reverse-engineer this behavior.
Filtering And Sorting Affect Database Design
Every exposed filter and sort can become a performance promise. If /users?status=active&sort=-created_at is a supported endpoint, the database likely needs an index that matches the common query shape. Filtering on unindexed columns can turn a harmless-looking endpoint into a table scan under traffic.
Review combinations, not just individual fields. A good index for status alone may not help when the endpoint also sorts by created_at and id. The database lesson on query performance covers index design in more detail; the API lesson's point is that the contract and the database cannot be designed independently.
Rate limits and maximum page sizes protect the system, but they do not excuse slow query design. Measure real queries with realistic data volumes before publishing a list endpoint as stable.
Versioning Is About Behavior, Not Only URLs
A version identifies a contract: fields, meanings, error codes, authentication expectations, pagination rules, and side effects. /v2 in the path is one way to select that contract, but not the only one.
Common strategies are:
- path versioning such as
/v1/users - media-type versioning such as
Accept: application/vnd.example.v1+json - date-based versioning such as
API-Version: 2026-06-10 - account-level feature versions managed by the provider
Path versioning is visible and easy to debug. Header and date versions can reduce duplicated routes but require stronger tooling, logs, documentation, and support workflows.
The worst strategy is silent behavior drift. If the same request returns different meanings over time without negotiation or deprecation, clients cannot safely integrate.
Classify API Changes Deliberately
Usually compatible:
- adding an optional response field
- adding a new endpoint
- accepting an additional optional request field
- adding a new filter that clients do not use by default
- documenting a previously undocumented error that already happened
Potentially breaking:
- removing or renaming a field
- changing a field type or nullability
- changing default sort order or page size
- changing cursor format without supporting old cursors long enough
- adding a required request field
- changing error code semantics
- adding enum values when clients treat the enum as exhaustive
- changing authorization requirements
- changing rate limits or pagination caps dramatically
Some "compatible" additions still break brittle clients. Generated clients and strict schema validators may reject unknown fields if configured poorly. Public API documentation should tell clients whether they must ignore unknown fields and tolerate unknown enum values.
Deprecation Needs A Migration Path
When removing or changing behavior, provide a date, replacement, and visibility. Add deprecation headers or response metadata where appropriate, email known consumers, update documentation, and measure usage.
A useful deprecation notice states:
- what is changing
- why it is changing
- when old behavior stops
- how to migrate
- how to test the new behavior
- who to contact if migration is blocked
Do not deprecate a field without knowing who still uses it if the API is external or cross-team. Logs, analytics, or gateway metrics should record version, route, consumer, and selected deprecated features where privacy and policy allow.
Error Responses For List Parameters
Invalid filters, cursors, and sort fields should produce clear client errors. Do not silently ignore a misspelled filter such as statsu=active; clients may think the result is filtered when it is not.
Return a machine-readable error code and field or parameter name:
<?php
declare(strict_types=1);
function invalidParameter(string $name, string $message): array
{
return [
'errors' => [[
'code' => 'invalid_parameter',
'parameter' => $name,
'message' => $message,
]],
];
}
echo json_encode(invalidParameter('sort', 'Sort field is not supported.'), JSON_THROW_ON_ERROR) . PHP_EOL;
// Prints:
// {"errors":[{"code":"invalid_parameter","parameter":"sort","message":"Sort field is not supported."}]}
For public APIs, document whether invalid parameters use 400 Bad Request or 422 Unprocessable Content and keep the convention consistent.
What To Check
Before moving on, make sure you can:
- cap page size and calculate an offset
- explain offset drift and when cursor pagination is useful
- build opaque cursors from stable sort values
- preserve filters and sorting in navigation links
- validate filters and sort fields with allow-lists
- document exact filter semantics, boolean parsing, and repeated parameters
- connect API list contracts to database indexes and query plans
- identify compatible, risky, and breaking API changes
- plan deprecation with a migration path and usage measurement
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.
Practice: Build Opaque Cursors
Create a cursor helper for a users endpoint sorted by created_at desc, id desc.
Task
Write functions that:
- encode the last item's
created_atandid - use URL-safe base64
- decode the cursor back into an array
- reject invalid base64, invalid JSON, or missing fields
- build a
nextlink that preservesstatus,sort, andper_page
Demonstrate one valid cursor and one invalid cursor.
Check Your Work
Explain why the cursor contains both sort values and why clients should treat it as opaque.
Show solution
The cursor carries the values needed to resume the stable sort after the last item.
<?php
declare(strict_types=1);
function encodeUserCursor(string $createdAt, int $id): string
{
$json = json_encode(['created_at' => $createdAt, 'id' => $id], JSON_THROW_ON_ERROR);
return rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
}
function decodeUserCursor(string $cursor): array
{
$json = base64_decode(strtr($cursor, '-_', '+/'), true);
if ($json === false) {
throw new InvalidArgumentException('Cursor is not valid base64.');
}
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
if (!is_array($data) || !isset($data['created_at'], $data['id'])) {
throw new InvalidArgumentException('Cursor has an invalid shape.');
}
return $data;
}
function nextUserLink(array $query, string $cursor): string
{
unset($query['page']);
$query['cursor'] = $cursor;
return '/v1/users?' . http_build_query($query);
}
$cursor = encodeUserCursor('2026-06-10T09:30:00Z', 102);
print_r(decodeUserCursor($cursor));
echo nextUserLink(['status' => 'active', 'sort' => '-created_at', 'per_page' => 50], $cursor) . PHP_EOL;
try {
decodeUserCursor('not valid base64');
} catch (Throwable $exception) {
echo 'invalid cursor' . PHP_EOL;
}
// Prints:
// [created_at] => 2026-06-10T09:30:00Z
// [id] => 102
// /v1/users?status=active&sort=-created_at&per_page=50&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wNi0xMFQwOTozMDowMFoiLCJpZCI6MTAyfQ
// invalid cursor
The cursor includes both created_at and id because the endpoint sorts by both values. Clients pass the cursor back; they should not build or edit it.
Practice: Classify API Changes
Review proposed changes to a public /v1/users API.
Task
Classify each change as usually compatible, risky, or breaking:
- add optional
avatar_urlto the user response - rename
emailtoemail_address - change
idfrom string to integer - add
status=suspendedas a new enum value - change default sort from
created_at desctoname asc - lower
per_pagemaximum from100to25 - add optional
created_afterfilter - require
tenant_idon every request - change invalid cursor errors from
400to404
For every risky or breaking change, propose a migration or versioning strategy.
Show solution
Adding optional avatar_url is usually compatible if clients ignore unknown fields. Adding optional created_after is also usually compatible because existing requests behave the same.
Renaming email, changing id from string to integer, requiring tenant_id, and changing invalid cursor errors from 400 to 404 are breaking. They alter fields, input requirements, or documented error semantics. Keep the old behavior in v1, add the new behavior in v2 or behind an explicit version header, and publish migration examples.
Adding status=suspended is risky. Clients with exhaustive enum handling may fail. Document unknown-value handling first, add consumer warnings if possible, and consider a version or feature flag for strict clients.
Changing the default sort is breaking for clients relying on stable order. Add a new explicit sort value, keep the old default in v1, and change it only in a new version.
Lowering per_page maximum from 100 to 25 is risky to breaking. Some clients may depend on the old page size for batch processing. Announce the limit, measure usage, support both during a deprecation period, and return clear errors or warnings before enforcing the new cap.