You are browsing a version that has not yet been released. |
Pagination
Doctrine ORM provides two pagination strategies for DQL queries. Both handle the low-level SQL plumbing, but they make different trade-offs:
| Feature | Offset Paginator |
CursorPaginator |
|---|---|---|
| Total count | Yes | No |
| Random access to page N | Yes | No |
| Stable under concurrent inserts/deletes | No | Yes |
| Performance on deep pages | Degrades (OFFSET scan) | Constant (index range scan) |
| Requires deterministic ORDER BY | No | Yes |
Choose the Offset Paginator when you need a total page count or want to let users jump to an arbitrary page number.
Choose the Cursor Paginator when you need stable, high-performance pagination on large datasets and a simple previous/next navigation is sufficient.
Offset-Based Pagination
Doctrine ORM ships with a Paginator for DQL queries. It
has a very simple API and implements the SPL interfaces Countable and
IteratorAggregate.
<?phpuse Doctrine\ORM\Tools\Pagination\Paginator;$dql = "SELECT p, c FROM BlogPost p JOIN p.comments c";$query = $entityManager->createQuery($dql) ->setFirstResult(0) ->setMaxResults(100);$paginator = new Paginator($query, fetchJoinCollection: true);$c = count($paginator);foreach ($paginator as $post) { echo $post->getHeadline() . "\n";}
Paginating Doctrine queries is not as simple as you might think in the beginning. If you have complex fetch-join scenarios with one-to-many or many-to-many associations using the "default" LIMIT functionality of database vendors is not sufficient to get the correct results.
By default the pagination extension does the following steps to compute the correct result:
- Perform a Count query using
DISTINCTkeyword. - Perform a Limit Subquery with
DISTINCTto find all ids of the entity in from on the current page. - Perform a WHERE IN query to get all results for the current page.
This behavior is only necessary if you actually fetch join a to-many
collection. You can disable this behavior by setting the
fetchJoinCollection argument to false; in that case only 2 instead of the 3 queries
described are executed. We hope to automate the detection for this in
the future.
|
|
By using the Paginator::HINT_ENABLE_DISTINCT you can instruct doctrine that the query to be executed
will not produce "duplicate" rows (only to-one relations are joined), thus the SQL limit will work as expected.
In this way the DISTINCT keyword will be omitted and can bring important performance improvements.
<?phpuse Doctrine\ORM\Tools\Pagination\Paginator;$dql = "SELECT u, p FROM User u JOIN u.mainPicture p";$query = $entityManager->createQuery($dql) ->setHint(Paginator::HINT_ENABLE_DISTINCT, false) ->setFirstResult(0) ->setMaxResults(100);
Cursor-Based Pagination
Doctrine ORM ships with a CursorPaginator for cursor-based pagination of DQL queries.
Unlike offset-based pagination, cursor pagination uses opaque pointers (cursors) derived
from the last seen row to fetch the next or previous page. This makes it stable and
performant on large datasets — no matter how deep you paginate, the database always uses
an index range scan instead of skipping rows.
|
Cursor pagination requires a deterministic ``ORDER BY`` clause. Every column combination used for sorting must uniquely identify a position in the result set. A common pattern is to sort by a timestamp and then by primary key as a tie-breaker. |
Basic Usage
The $cursor parameter is an opaque string produced by a previous call to
getNextCursorAsString() or getPreviousCursorAsString(). On the first request
it is null or an empty string '' — both are treated identically as the first
page. It is typically read from the incoming HTTP query string:
$cursor = $_GET['cursor'] ?? null; // null or '' on the first page
<?phpuse Doctrine\ORM\Tools\CursorPagination\CursorPaginator;$dql = 'SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC';$query = $entityManager->createQuery($dql);$paginator = (new CursorPaginator($query)) ->paginate(cursor: $cursor, limit: 15);foreach ($paginator as $post) { echo $post->getTitle() . "\n";}echo $paginator->getPreviousCursorAsString(); // previous encoded cursor stringecho $paginator->getNextCursorAsString(); // next encoded cursor string
API Reference
CursorPaginator::paginate(?string $cursor, int $limit): self- Executes the query and stores the results. Fetches
$limit + 1rows to detect whether a further page exists, then trims the extra row. Returns$thisfor chaining. CursorPaginator::getNextCursor(): Cursor- Returns the
Cursorobject for the next page. Throws aLogicExceptionif there is no next page — callhasNextPage()first. CursorPaginator::getPreviousCursor(): Cursor- Returns the
Cursorobject for the previous page. Throws aLogicExceptionif there is no previous page — callhasPreviousPage()first. CursorPaginator::getNextCursorAsString(): string- Returns the encoded cursor to retrieve the next page. Throws a
LogicExceptionif there is no next page — callhasNextPage()first. CursorPaginator::getPreviousCursorAsString(): string- Returns the encoded cursor to retrieve the previous page. Throws a
LogicExceptionif there is no previous page — callhasPreviousPage()first. CursorPaginator::hasNextPage(): bool- Returns whether a next page is available.
CursorPaginator::hasPreviousPage(): bool- Returns whether a previous page is available.
CursorPaginator::hasToPaginate(): bool- Returns whether either a next or previous page exists (i.e. the result set spans more than one page).
CursorPaginator::getValues(): array- Returns the raw entity array for the current page.
CursorPaginator::getItems(): array- Returns an array of
CursorItemobjects, each wrapping an entity and its individualCursor. Useful when you need per-row cursors. CursorPaginator::getCursorForItem(mixed $item, bool $isNext = true): Cursor- Builds a
Cursorpointing at a specific entity.$isNext = truemeans "start after this item";falsemeans "start before this item". CursorPaginator::count(): int- Returns the number of items on the current page (implements
Countable).
Next page
SELECT ...FROM post pWHERE (p.created_at < :cursor_val_0) OR (p.created_at = :cursor_val_0 AND p.id < :cursor_id_1)ORDER BY p.created_at DESC, p.id DESCLIMIT 16 -- limit + 1
Previous page
SELECT ...FROM post pWHERE (p.created_at > :cursor_val_0) OR (p.created_at = :cursor_val_0 AND p.id > :cursor_id_1)ORDER BY p.created_at ASC, p.id ASC -- reversedLIMIT 16
HTML Template Example
The following example shows how to render a paginated list with previous/next
navigation links using the CursorPaginator in a PHP template:
<?phpuse Doctrine\ORM\Tools\CursorPagination\CursorPaginator;$cursor = $_GET['cursor'] ?? null;$query = $entityManager->createQuery('SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC');/** @var CursorPaginator<BlogPost> $paginator */$paginator = (new CursorPaginator($query)) ->paginate(cursor: $cursor, limit: 15);?><p><?= $paginator->count() ?> result(s) on this page.</p><ul> <?php foreach ($paginator as $post): ?> <li><?= escape($post->getTitle()) ?></li> <?php endforeach ?></ul><?php if ($paginator->hasToPaginate()): ?> <nav> <?php if ($paginator->hasPreviousPage()): ?> <a href="?cursor=<?= escape($paginator->getPreviousCursorAsString()) ?>">Previous</a> <?php endif ?> <?php if ($paginator->hasNextPage()): ?> <a href="?cursor=<?= escape($paginator->getNextCursorAsString()) ?>">Next</a> <?php endif ?> </nav><?php endif ?>
Cursor Encoding
A cursor is serialized to a URL-safe string via Cursor::encodeToString() and
deserialized back via the static Cursor::fromEncodedString(). The format is a
JSON object encoded with URL-safe Base64 (no padding):
{ "p.createdAt": "2024-01-15T10:30:00+00:00", "p.id": 42, "_isNext": true}
The _isNext flag distinguishes next-page cursors from previous-page cursors.
All other keys are the DQL path expressions (alias.field) of the ORDER BY
columns, and their values are the database representations of the pivot row's
field values.
If you need a different serialization format (e.g. encryption), build it on top of
a Cursor instance: call $cursor->toArray() to get the raw data, apply your
own encoding, and reconstruct with new Cursor($parameters, $isNext).
Limitations
- Every
ORDER BYcolumn must map to an entity field. Raw SQL expressions or computed columns inORDER BYare not supported. COUNTqueries are not available; cursor pagination does not know the total number of results by design. If you need a total count, use the offset-basedPaginatordescribed above.- The query must have at least one
ORDER BYitem; the paginator throws aLogicExceptionotherwise.
