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 example
<?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 example
<?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.

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 example
<?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 example
<?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 example
<?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 page and per_page, with sensible defaults.
  • Cap per_page at 100.
  • Support a status filter with an allow-list.
  • Support sort, including descending sort with a leading -.
  • Reject unknown sort fields.
  • Return a response shape containing data, meta, and links.next.
Show solution

This solution validates query parameters before they can affect a database query.

PHP example
<?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.