Skip to content

Custom data-source adapters

Edit this page

An adapter connects the paginator to a data source. Write one when the built-in array, Doctrine ORM and Doctrine DBAL adapters do not cover your source.

Implement only the capabilities the source actually guarantees. Every capability interface extends PaginationAdapterInterface, which defines supports() and carries the ux_pagination.adapter autoconfiguration tag.

For an offset source with an exact total, implement OffsetAdapterInterface:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/Pagination/SearchQueryAdapter.php
namespace App\Pagination;

use App\Search\SearchQuery;
use Symfony\UX\Pagination\Adapter\OffsetAdapterInterface;

final class SearchQueryAdapter implements OffsetAdapterInterface
{
    public function supports(mixed $source): bool
    {
        return $source instanceof SearchQuery;
    }

    public function slice(mixed $source, int $offset, int $limit): array
    {
        \assert($source instanceof SearchQuery);

        return $source->fetch(offset: $offset, limit: $limit);
    }

    public function count(mixed $source): int
    {
        \assert($source instanceof SearchQuery);

        return $source->count();
    }
}

With the usual service autoconfiguration, no manual tag is required.

Capabilities

Interface Source capability
PaginationAdapterInterface Source recognition through supports()
OffsetAdapterInterface Offset slice and exact count
LookaheadAdapterInterface Offset slice with N+1 hasMore detection
CursorAdapterInterface Opaque forward/backward cursor traversal

The interfaces are independent. A remote GraphQL connection may implement only CursorAdapterInterface; it does not need dummy count() or offset-based slice() methods. The built-in array, Doctrine ORM and Doctrine DBAL adapters implement all three strategies.

A missing capability never changes the strategy silently. Requesting one that the adapter does not implement produces an immediate, adapter-specific exception.

Lookahead

Implement LookaheadAdapterInterface to support lookahead(). Its sliceWithLookahead() method returns [items, hasMore] and must never return more than the requested limit.

Cursor

Implement CursorAdapterInterface to support cursor pagination. It receives a decoded CursorBoundary and returns a CursorSlice containing the items, the next boundary, the previous boundary and the hasNext state.

resolveCursorOrder() returns the effective CursorOrder. The cursor codec binds it to signed tokens before a boundary is decoded or created.

For a field-based source, validate the fields passed to orderBy() and append a unique tie-breaker when the source can derive one:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public function resolveCursorOrder(
    mixed $source,
    ?array $fields,
    ?string $direction,
): CursorOrder {
    \assert($source instanceof SearchQuery);

    if (null === $fields || null === $direction) {
        throw new \InvalidArgumentException('Call orderBy() for this source.');
    }

    if (!\in_array('objectID', $fields, true)) {
        $fields[] = 'objectID';
    }

    return CursorOrder::byFields($fields, $direction);
}

For a remote connection that owns its order, reject an application-supplied field order and return a stable opaque identity. Callers then omit orderBy():

1
2
3
4
5
6
7
8
9
10
11
12
13
public function resolveCursorOrder(
    mixed $source,
    ?array $fields,
    ?string $direction,
): CursorOrder {
    \assert($source instanceof SearchQuery);

    if (null !== $fields || null !== $direction) {
        throw new \InvalidArgumentException('This API owns its cursor order.');
    }

    return CursorOrder::byIdentity('search:created_at_desc:v1');
}

Changing the opaque identity invalidates existing tokens. Include the remote sort mode or a version in it, but never credentials.

The adapter also implements getCursorContext(). Return a deterministic description of everything that defines the logical source query but is not already part of the order. The cursor codec binds this value to every signed token.

The second argument is the optional value supplied by the application through cursor()->context(). Include it when it scopes the source. A custom adapter may require it, ignore it, or combine it with source data, but the resulting context must be deterministic.

For a remote API, that context typically contains the endpoint, the filters and the tenant or repository identity. It must not contain the current page items:

1
2
3
4
5
6
7
8
9
10
public function getCursorContext(mixed $source, ?string $context): string
{
    \assert($source instanceof SearchQuery);

    return json_encode([
        'index' => $source->index,
        'filters' => $source->filters,
        'context' => $context,
    ], \JSON_THROW_ON_ERROR);
}

Adapter design

  • Make supports() precise. The highest-priority matching adapter wins.
  • Do not mutate the caller's source object.
  • Return stable list ordering.
  • Keep count and slice filters identical.
  • Return the same cursor context for every page of the same logical query.
  • Return the complete deterministic order from resolveCursorOrder().
  • Reject unsupported cursor fields and directions.
  • Add contract tests for empty, first, middle and last pages in both directions.

Adapter priority

Adapters use the standard Symfony tagged-service priority rules. Return a default priority from the adapter when it should normally run before another adapter supporting the same source:

1
2
3
4
public static function getDefaultPriority(): int
{
    return 100;
}

An explicit priority attribute on the ux_pagination.adapter service tag takes precedence. Higher values run first; equal priorities keep service registration order. Most adapters should keep the implicit priority 0 and use a narrow supports() implementation.

For a one-off offset integration that can provide both a slice callback and an exact count callback, prefer fromCallbacks() from Integrations. The returned builder also supports lookahead(), but fromCallbacks() does not support cursor pagination.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.
TOC
    Version