ORM-agnostic pagination for PHP 8.4 — offset, cursor, and simple paginators with RFC 8288 Link headers, JSON:API links, and property hooks.
Why Another Pagination Package?
| Feature | Laravel | Symfony | ML Pagination |
|---|---|---|---|
| Offset pagination | ✅ LengthAwarePaginator |
✅ PaginatorInterface |
✅ OffsetPaginator |
| Cursor pagination | ✅ CursorPaginator |
❌ | ✅ CursorPaginator |
| Simple (no count) | ✅ Paginator |
❌ | ✅ SimplePaginator |
| PHP 8.4 hooks | ❌ | ❌ | ✅ $p->hasMore, $p->lastPage |
| RFC 8288 Link headers | ❌ manual | ❌ | ✅ toLinkHeader() |
| JSON:API links | ❌ | ❌ | ✅ toJsonApiLinks() |
| ORM-agnostic | ❌ Eloquent-bound | ❌ Doctrine-bound | ✅ any iterable |
| Zero dependencies | ❌ | ❌ | ✅ pure PHP 8.4 |
| Configurable envelope | ❌ fixed format | ❌ | ✅ PaginationResult |
Installation
composer require monkeyscloud/monkeyslegion-pagination
Quick Start
Offset Pagination (with total count)
use MonkeysLegion\Pagination\OffsetPaginator;
$paginator = new OffsetPaginator(
items: $repository->findPage(page: 3, perPage: 25),
total: $repository->count(),
page: 3,
perPage: 25,
);
// PHP 8.4 property hooks
$paginator->lastPage; // 4
$paginator->hasMorePages; // true
$paginator->from; // 51
$paginator->to; // 75
// Metadata
$paginator->toMeta(); // ['current_page' => 3, 'last_page' => 4, ...]
// RFC 8288 Link header
$paginator->toLinkHeader('/api/users');
// <.../api/users?page=1&per_page=25>; rel="first", <...>; rel="next", ...
// JSON:API links
$paginator->toJsonApiLinks('/api/users');
// ['self' => '...page[number]=3', 'first' => ..., 'prev' => ..., 'next' => ...]
Cursor Pagination (keyset, no total)
use MonkeysLegion\Pagination\CursorPaginator;
$paginator = new CursorPaginator(
items: $users,
perPage: 25,
cursor: $request->getQueryParams()['cursor'] ?? null,
nextCursor: $users[count($users) - 1]->id ?? null,
previousCursor: $firstId,
);
$paginator->hasMorePages; // true
$paginator->isFirstPage; // false
$paginator->total(); // null (unknown by design)
Simple Pagination (no count query)
use MonkeysLegion\Pagination\SimplePaginator;
// Fetch perPage + 1 items → auto-detects hasMore
$items = $repository->findAll(limit: 26, offset: 50);
$paginator = new SimplePaginator(
items: $items, // Pass 26 items → strips to 25, hasMore=true
page: 3,
perPage: 25,
);
$paginator->hasMorePages; // true (auto-detected)
$paginator->count(); // 25
$paginator->total(); // null
PaginationResult (envelope + transformer)
use MonkeysLegion\Pagination\PaginationResult;
$result = new PaginationResult($paginator);
// With transformer callback
$json = $result->toJson(fn(User $u) => [
'id' => $u->id,
'email' => $u->email,
]);
// Custom wrapper
$result->withWrap('items')->toArray();
// { "items": [...], "meta": { ... } }
// No wrapper
$result->withWrap(null)->toArray();
// [...]
Response Integration
// In a MonkeysLegion controller
return new JsonResponse([
...(new PaginationResult($paginator))->toArray(
fn(User $u) => UserResource::make($u)->toArray(),
),
], headers: [
'Link' => $paginator->toLinkHeader($request->getUri()->getPath()),
]);
Paginators Comparison
OffsetPaginator |
CursorPaginator |
SimplePaginator |
|
|---|---|---|---|
| Needs total count | ✅ Yes | ❌ No | ❌ No |
| Needs cursor | ❌ No | ✅ Yes | ❌ No |
| O(1) seek | ❌ (OFFSET) | ✅ (WHERE id >) | ❌ (OFFSET) |
| Concurrent-safe | ⚠️ Drift | ✅ Stable | ⚠️ Drift |
lastPage |
✅ | ❌ | ❌ |
| JSON:API links | ✅ | ✅ | ❌ |
| Best for | Admin panels | APIs, feeds | Quick lists |
License
MIT © MonkeysCloud Team
Related Packages
monkeyslegion-serializerv1.0.0
UtilitiesAttribute-driven object↔JSON/XML serializer for MonkeysLegion: property hooks, readonly DTOs, normalizer pipeline, naming strategies, PHPStan Level 9.
monkeyslegion-encryptionv1.0.0
UtilitiesStandalone encryption for MonkeysLegion v2 — AES-GCM, XChaCha20, HMAC, key rotation, envelope encryption, PHP 8.4 hooks