Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Lib Pagination Laravel Package

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.

Deep Wiki
Context7

Getting Started

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:

  1. The entry point — is it a 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.
  2. The result object — what does a page give you? Look for 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)

Implementation Patterns

Pattern 1: A thin repository/service method per collection

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.

Pattern 2: Two endpoints, same data, different shapes

A common and sane split:

  • GET /orders?page=3&limit=20 → offset, returns meta.total
  • GET /feed?cursor=eyJ...&limit=20 → cursor, returns meta.next_cursor

Normalise 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.

Pattern 3: Map request input to a value object, not to loose scalars

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.

Pattern 4: Cursor passthrough for stateless clients

Cursor mode is stateless by design — the cursor is the state. That means:

  • Store the cursor in the URL, not in the session.
  • Never mutate the sort order between pages, or the cursor becomes meaningless.
  • If you expose a cursor to clients, treat it as opaque. Base64-encoded JSON is fine for debugging but sign it (HMAC) if it contains anything you don't want users editing — see Gotchas.
// page 1
$page = $paginator->paginate($qb, limit: 20);
// page 2
$page = $paginator->paginate($qb, limit: 20, cursor: $page->getNextCursor());

Pattern 5: Sorting must be whitelisted, never user-formatted

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');

Pattern 6: Testing

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:

  • Insert 25 rows, limit=10: expect 10 / 10 / 5 and hasNext() true, true, false.
  • Force duplicate sort values (three rows with identical createdAt) and assert none are skipped across pages.
  • Request page 0 and page 1000: expect sane empty results, not exceptions.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings