paysera/lib-pagination
A lightweight PHP pagination library from Paysera for splitting data sets into pages. It calculates offsets, page counts, and navigation ranges, helping developers build paginated lists and search results in web applications.
Install and inspect before writing anything else.
composer require paysera/lib-pagination
Then spend ten minutes in vendor/paysera/lib-pagination/src/ and its tests/. Two things determine everything you'll do afterwards:
Paginator/Pagination service, a set of strategy classes (OffsetPagination, CursorPagination), or a static factory? The whole library is small (14 stars, one job), so the public surface is likely 3–6 classes.getItems()/getData(), getNextCursor(), hasNext(), getTotalCount(). This is the contract your controllers and API responses will be built around.The naming above is the conventional shape for this kind of library; confirm the exact FQCNs in src/ before typing them into a service.
First use case: paginate one existing repository query.
Start with the simplest path — offset pagination — on a query you already have, before introducing cursors:
use App\Repository\OrderRepository;
$queryBuilder = $orderRepository->createActiveOrdersQueryBuilder();
$page = $this->paginator->paginate($queryBuilder, page: 1, limit: 20);
$items = $page->getItems(); // array of Order entities
$total = $page->getTotalCount(); // int (if the strategy offers it)
$nextCursor = $page->getNextCursor(); // null for offset mode
If that works end-to-end in a controller and returns entities, you're 80% of the way. Only then move on to cursor pagination for the endpoints that actually need it (feeds, exports, anything with deep paging).
Decision rule for which mode to use on day one:
| Situation | Use |
|---|---|
| Admin grid, "page 4 of 37", jump-to-page | Offset |
| Infinite scroll, mobile feed, deep paging | Cursor |
| Export a large result set | Cursor (or toIterable() + batching) |
| Needs an exact total count | Offset (or cursor + cached count) |
Don't let pagination leak into controllers. Wrap it:
final class OrderQuery
{
public function __construct(private Paginator $paginator) {}
public function activeOrders(PaginationRequest $request): Page
{
$qb = $this->em->createQueryBuilder()
->select('o')
->from(Order::class, 'o')
->where('o.status = :status')
->setParameter('status', 'active')
->orderBy('o.createdAt', 'DESC')
->addOrderBy('o.id', 'DESC'); // tiebreaker — non-negotiable
return $this->paginator->paginate($qb, $request->toPaginationParams());
}
}
The addOrderBy('o.id', 'DESC') tiebreaker is the single most important line in any cursor implementation. Without a unique final sort key, rows with identical createdAt will be skipped or repeated at page boundaries.
A common and sane split:
GET /orders?page=3&limit=20 → offset, returns meta.totalGET /feed?cursor=eyJ...&limit=20 → cursor, returns meta.next_cursorNormalise the response envelope once so clients only deal with one shape:
return [
'data' => array_map($serializer, $page->getItems()),
'meta' => [
'next_cursor' => $page->getNextCursor(), // null in offset mode
'has_more' => $page->hasNext(),
'total' => $page->getTotalCount(), // omit if not available
],
];
Treat total as optional in your API contract. Cursor strategies frequently can't produce it cheaply, and clients that hard-depend on it will break the day you switch a feed to cursors.
Pagination params arrive as strings, get validated, and then get handed to the library. One immutable object keeps the controller clean and makes the query layer testable without HTTP:
final class PaginationRequest
{
public static function fromRequest(Request $request, int $maxLimit = 100): self
{
$limit = min(max((int) $request->query('limit', 20), 1), $maxLimit);
$cursor = $request->query('cursor');
$page = max((int) $request->query('page', 1), 1);
return new self(limit: $limit, cursor: $cursor, page: $page);
}
}
Clamp the limit server-side. A client asking for limit=100000 should get 100, not an OOM.
Cursor mode is stateless by design — the cursor is the state. That means:
// page 1
$page = $paginator->paginate($qb, limit: 20);
// page 2
$page = $paginator->paginate($qb, limit: 20, cursor: $page->getNextCursor());
ORDER BY cannot be parameter-bound in DQL/SQL, so any library building it from input is a potential injection point. Do the mapping yourself:
private const SORTABLE = [
'created_at' => 'o.createdAt',
'total' => 'o.total',
'id' => 'o.id',
];
$field = self::SORTABLE[$request->query('sort', 'created_at')] ?? 'o.createdAt';
$dir = $request->query('dir') === 'asc' ? 'ASC' : 'DESC';
$qb->orderBy($field, $dir)->addOrderBy('o.id', 'DESC');
The library's behaviour is mostly "does the boundary math work". Test it at the repository level with a fixed fixture set, not through HTTP:
limit=10: expect 10 / 10 / 5 and hasNext() true, true, false.createdAt) and assert none are skipped across pages.The QueryBuilder is mutated in place. Pagination libraries typically call setMaxResults()/setFirstResult() and sometimes setParameter() on the builder you hand them. If you reuse that builder afterwards — for a count, for an export, for a second page in the same request — you inherit the previous page's limits. Clone it: $countQb = clone $qb;. This is the most common source of "why is my export only 20 rows" bugs.
Grouping matters when appending WHEREs. If the library adds keyset predicates and your base query already contains an OR (e.g. status = 'active' OR status = 'pending'), SQL operator precedence will silently break the filter:
// wrong: AND binds tighter than OR
$qb->where('o.a = 1 OR o.b = 2');
// the library appends: AND (o.createdAt, o.id) < (:c1, :c2)
// → o.a = 1 OR (o.b = 2 AND cursor...)
// correct
$qb->where($qb->expr()->orX('o.a = 1', 'o.b = 2'));
Wrap any existing compound condition in parentheses before pagination touches the builder.
Cursors need a unique tiebreaker, or they lose rows. Lexicographic keyset predicates (createdAt, id) < (:cCreatedAt, :cId) are only correct if (createdAt, id) is unique and the sort order of both columns matches. If createdAt is DESC and id is ASC, the composite predicate must flip too. Add an indexed id (or a monotonic sequence) as the last ORDER BY and mirror it in the predicate.
Duplicate rows from fetch-joins. SELECT o, i FROM Order o JOIN o.items i yields one row per item. If your paginator applies setMaxResults() to that, you paginate rows, not orders — page sizes become inconsistent and the last order of a page can be truncated mid-collection. Fix by paginating IDs only and re-hydrating, or by using a distinct/GROUP BY strategy carefully. Doctrine's own Paginator has a "fetch-join collection" hint for this; check whether this library exposes anything similar, and if not, do the two-step: page over SELECT o.id, then fetch entities for that ID list.
Offset pagination degrades and drifts. LIMIT 20 OFFSET 20000 still scans 20 020 rows. And if a row is inserted while a user pages, items shift and they see duplicates. That's acceptable for admin grids, unacceptable for feeds. Budget for switching the hot endpoints to cursors.
Don't run the count query on every request. getTotalCount() typically issues a second SELECT COUNT(...). On large tables with joins that can cost more than the page itself. Cache it (short TTL, keyed by filter set) or drop it from the response and return has_more only.
Cursors are user-visible input. If the library base64-encodes a payload, users can decode it, edit the timestamp, and re-encode it. Decide deliberately: either the cursor contains nothing sensitive (just sort values — usually fine, they're already in the response), or you HMAC-sign it with an app secret and reject tampered cursors with a 400. Never put user IDs or permissions in an unsigned cursor and trust them.
DateTime and null ordering are the classic silent bugs. Comparing datetimes across a timezone boundary, or sorting a nullable column, will produce boundary rows that appear on two consecutive pages. Store/compare in UTC, and if a sortable column is nullable, add NULLS LAST (or the equivalent) explicitly and test it.
Debugging: log the actual SQL. Doctrine's SQLLogger (or the profiler's query list in Symfony) is the fastest way to see what predicate the library generated. Almost every pagination bug is visible in one glance at the generated WHERE ... AND (o.created_at, o.id) < (?, ?) clause. If you can't read the SQL, you're guessing.
Extension points to look for before forking. Small libraries like this usually leave seams: a cursor encoder/decoder interface, a field-to-column mapping, a strategy interface you can implement for a custom pagination mode. Check src/ for interfaces before writing a subclass — and check composer.json for the supported PHP and Doctrine ORM versions, since a 14-star package may lag behind a Doctrine major version. Lock the constraint ("paysera/lib-pagination": "^1.0") and pin Doctrine accordingly.
Hydration choice is a performance lever. getArrayResult()/HYDRATE_SCALAR avoids entity hydration for read-only endpoints and can cut response time substantially on wide entities. If your response is immediately serialized, skip the entity entirely.
Empty pages are not necessarily the end. For cursor mode, if getItems() is empty, check hasNext() before terminating a client-side loop — a filtered page can be legitimately empty while later pages have data, depending on how the strategy handles limits. Read the library's behaviour once and encode it in your client contract.
How can I help you explore Laravel packages today?