Skip to content

marko/pagination

Offset and cursor pagination with API-ready serialization --- paginate any result set without coupling to your data layer. Pagination provides two strategies: offset-based (traditional page numbers) and cursor-based (for large or real-time datasets). Both produce structured arrays ready for JSON API responses. A config helper enforces per-page limits so clients cannot request unbounded result sets.

Terminal window
composer require marko/pagination

The most common approach --- pass items, total count, per-page, and current page:

use Marko\Pagination\OffsetPaginator;
$paginator = new OffsetPaginator(
items: $items,
total: 150,
perPage: 15,
currentPage: 3,
);
$paginator->items(); // Items for page 3
$paginator->hasMorePages(); // true
$paginator->lastPage(); // 10
$paginator->nextPage(); // 4
$paginator->previousPage(); // 2

For large datasets or infinite scroll, use cursor-based pagination:

use Marko\Pagination\Cursor;
use Marko\Pagination\CursorPaginator;
$cursor = new Cursor(['id' => 42]);
$paginator = new CursorPaginator(
items: $items,
perPage: 20,
cursor: $cursor,
nextCursor: new Cursor(['id' => 62]),
);
$paginator->hasMorePages(); // true
$paginator->nextCursor()->encode(); // Base64-encoded cursor string

Parse a cursor string from a query parameter:

use Marko\Pagination\Cursor;
$cursor = Cursor::decode($request->getQueryParams()['cursor']);
$lastId = $cursor->parameter('id');

Both paginators serialize to a structured array:

$response = $paginator->toArray();
// ['items' => [...], 'meta' => [...], 'links' => [...]]

Offset paginators include total, per_page, current_page, and last_page in meta, with previous and next page numbers in links. Cursor paginators include per_page and has_more in meta, with base64-encoded previous and next cursor strings in links.

Use PaginationConfig to enforce server-side limits on client-requested page sizes:

use Marko\Pagination\Config\PaginationConfig;
public function __construct(
private PaginationConfig $paginationConfig,
) {}
public function list(
int $requestedPerPage,
): array {
$perPage = $this->paginationConfig->clampPerPage($requestedPerPage);
// $perPage is between 1 and max_per_page
}

PaginationConfig reads from the config repository using pagination.per_page and pagination.max_per_page keys.

Replace OffsetPaginator or CursorPaginator via Preferences to add custom serialization or metadata:

use Marko\Core\Attributes\Preference;
use Marko\Pagination\OffsetPaginator;
#[Preference(replaces: OffsetPaginator::class)]
class MyPaginator extends OffsetPaginator
{
public function toArray(): array
{
$data = parent::toArray();
$data['meta']['has_previous'] = $this->previousPage() !== null;
return $data;
}
}
public function items(): array;
public function total(): int;
public function perPage(): int;
public function currentPage(): int;
public function lastPage(): int;
public function hasMorePages(): bool;
public function previousPage(): ?int;
public function nextPage(): ?int;
public function toArray(): array;
public function items(): array;
public function perPage(): int;
public function hasMorePages(): bool;
public function cursor(): ?CursorInterface;
public function nextCursor(): ?CursorInterface;
public function previousCursor(): ?CursorInterface;
public function toArray(): array;
public function parameters(): array;
public function parameter(string $name): mixed;
public function encode(): string;
public static function decode(string $encoded): static;
public function perPage(): int;
public function maxPerPage(): int;
public function clampPerPage(int $requested): int;

Thrown when invalid values are provided. Extends MarkoException with context and suggestions.

Factory MethodTrigger
invalidPage(int $page)Page number less than 1
invalidPerPage(int $perPage)Per-page value less than 1
invalidCursor(string $encoded)Cursor string that cannot be base64/JSON decoded