Pagination
When an application lists documents, it usually displays one page at a time instead of the whole collection. Loading everything wastes memory, bandwidth and database time, and most screens only show a small part of the result set. Paginators keep the cost of a page bounded by the number of items per page and let MongoDB do the skipping, limiting and counting.
Doctrine MongoDB ODM provides 4 paginator classes. There are 2 pagination styles, and each style is available for the query builder and the aggregation builder:
- Offset pagination uses a page number and a number of items per page.
- Cursor pagination uses the value of the last item of the previous page.
All paginators implement IteratorAggregate, so they can be iterated directly
with foreach. Each paginator clones the builder it receives, which means the
original builder is not modified and the paginator can be iterated more than
once.
|
Paginators are available since ODM 2.18. |
What paginators are for
Paginators are read-only query helpers. They do not change documents, they do not persist anything, and they do not modify the builder you pass to them. They are useful whenever an application lists documents:
- A list screen with numbered pages and previous/next links.
- An API endpoint that accepts
pageandperPageparameters, or anaftercursor parameter. - Infinite scrolling or a "load more" button.
- A large aggregation pipeline whose result must be displayed in chunks.
Offset pagination
Offset pagination takes a page number, where the first page is 1, and a number
of items per page (24 by default). QueryOffsetPaginator wraps a query builder and
AggregationOffsetPaginator wraps an aggregation builder. Both classes
implement Countable, and count() returns the number of pages.
The results of the current page are fetched with the skip and limit
options of the query, or with the $skip and $limit stages of the
pipeline. Counting runs a separate query: a count query for the query
builder, or a $count stage that writes the result to a numDocuments
field for the aggregation builder. Hydration is disabled for that count
pipeline.
Query builder
<?phpuse Doctrine\ODM\MongoDB\Pagination\QueryOffsetPaginator;$queryBuilder = $dm->createQueryBuilder(User::class) ->field('status')->equals('active') ->sort('createdAt', 'desc');$paginator = new QueryOffsetPaginator($queryBuilder, page: 2, perPage: 20);foreach ($paginator as $user) { // use the 20 users of the second page}$pageCount = count($paginator); // number of pages
Aggregation builder
<?phpuse Doctrine\ODM\MongoDB\Pagination\AggregationOffsetPaginator;$aggregationBuilder = $dm->createAggregationBuilder(User::class);$aggregationBuilder ->match() ->field('status')->equals('active') ->sort('createdAt', -1);$paginator = new AggregationOffsetPaginator($aggregationBuilder, page: 2, perPage: 20);foreach ($paginator as $user) { // use the user}$pageCount = count($paginator); // number of pages
The results of the page are hydrated according to the hydration setting of the builder. See Hydration for details.
|
Always add a deterministic sort to the builder. Without it, MongoDB returns documents in natural order, which is not stable between two executions and can result in duplicated or skipped documents across pages. Do not add |
Cursor pagination
Cursor pagination returns the documents that come after a cursor value. It scales better than offset pagination on large collections because MongoDB can use an index on the cursor field instead of scanning the skipped documents.
QueryCursorPaginator wraps a query builder and
AggregationCursorPaginator wraps an aggregation builder. Their constructor
accepts:
after: the cursor value. Usenull(the default) for the first page, then the value of the cursor field on the last item of the previous page.perPage: the number of items per page (24 by default).field: the field used as cursor (idby default, which maps to the identifier field).
Both paginators read the sort direction from the builder. With an ascending
sort they keep the documents greater than after; with a descending sort they
keep the documents lower than after. If the builder does not sort, ascending
is assumed. Always sort on the cursor field as the primary sort: otherwise the
order of the results does not match the comparison and pages are inconsistent or
empty. The cursor field should be unique, for example the identifier field,
otherwise documents that share the same value can be skipped.
Query builder
<?phpuse Doctrine\ODM\MongoDB\Pagination\QueryCursorPaginator;$queryBuilder = $dm->createQueryBuilder(User::class) ->field('status')->equals('active') ->sort('id', 'asc');$paginator = new QueryCursorPaginator($queryBuilder, perPage: 20);$lastUser = null;foreach ($paginator as $user) { // use the user $lastUser = $user;}// Build the paginator for the next page from the last item$nextPage = new QueryCursorPaginator($queryBuilder, after: $lastUser->id, perPage: 20);
Aggregation builder
<?phpuse Doctrine\ODM\MongoDB\Pagination\AggregationCursorPaginator;$aggregationBuilder = $dm->createAggregationBuilder(User::class);$aggregationBuilder ->match() ->field('status')->equals('active') ->sort('id', 1);$paginator = new AggregationCursorPaginator($aggregationBuilder, perPage: 20);$lastUser = null;foreach ($paginator as $user) { // use the user $lastUser = $user;}$nextPage = new AggregationCursorPaginator( $aggregationBuilder, after: $lastUser->id, perPage: 20,);
|
The results of an aggregation are hydrated according to the hydration
setting of the builder. Call |
Choosing between offset and cursor pagination
Use offset pagination when the application needs the total number of pages, or
when users can jump to an arbitrary page. The count query has a cost on large
collections, and the cost of skip grows with the page number because
MongoDB scans the skipped documents.
Use cursor pagination for infinite scrolling or previous/next navigation. Each page costs roughly the same, whatever its position, but there is no page count and no way to jump to an arbitrary page.
Practical tips
- Clamp the page number to at least 1 before creating an offset paginator.
- Keep
perPagemoderate. A page is held in memory, and very large pages defeat the point of paginating. - Reuse the same builder for every page, without limit or offset.
- Do not call
count()when the screen only needs previous/next navigation. Cursor pagination does not need a count at all. - For cursor pagination, sort on the cursor field as the primary sort, in the direction you want. Both paginators read the direction from the builder. Use a unique field, and index it.
