Doctrine
  • Projects
    ORM DBAL MongoDB ODM Coding Standard Collections Common Data fixtures Event Manager Inflector Instantiator Lexer Migrations PHPCR ODM Persistence RST Parser View All
  • Development
    Community Contributor Workflow Maintainer Workflow Contribute to Website Policies GitHub Styleguide
  • Sponsorship
  • Partners
  • Blog
Edit

Tutorials

  • Getting Started with Doctrine
    • Guide Assumptions
    • What is Doctrine?
      • What are Entities?
    • An Example Model: Bug Tracker
    • Project Setup
    • Obtaining the EntityManager
    • Generating the Database Schema
    • Starting with the Product Entity
      • Adding behavior to Entities
    • Adding Bug and User Entities
    • Implementing more Requirements
    • Queries for Application Use-Cases
      • List of Bugs
      • Array Hydration of the Bug List
      • Find by Primary Key
    • Dashboard of the User
    • Number of Bugs
    • Updating Entities
    • Entity Repositories
    • Conclusion
  • Working with Indexed Associations
    • Mapping Indexed Associations
    • Querying indexed associations
    • Outlook into the Future
  • Extra Lazy Associations
    • Enabling Extra-Lazy Associations
  • Composite and Foreign Keys as Primary Key
    • General Considerations
    • Primitive Types only
    • Identity through foreign Entities
    • Use-Case 1: Dynamic Attributes
    • Use-Case 2: Simple Derived Identity
    • Use-Case 3: Join-Table with Metadata
    • Controlling composite key order
    • Performance Considerations
  • Ordering To-Many Associations
  • Override Field Association Mappings In Subclasses
  • Pagination
    • Offset-Based Pagination
      • How Offset Pagination Works
      • API Reference
      • Legacy Paginator
    • Cursor-Based Pagination
      • Constructor
      • Basic Usage
      • Navigating Pages
      • API Reference
      • HTML Template Example
      • Cursor Encoding
      • Limitations
    • Writing Strategy-Agnostic Code
  • Separating Concerns using Embeddables
    • Initializing embeddables
    • Column Prefixing
    • DQL

Reference

  • Architecture
    • Using an Object-Relational Mapper
    • Requirements
    • Doctrine ORM Packages
      • Collection, Event Manager and Persistence
      • The DBAL Package
      • The ORM Package
    • Terminology
      • Entities
      • Mapped Superclasses
      • Transient Classes
      • Entity states
      • Persistent fields
      • Serializing entities
      • The EntityManager
      • Transactional write-behind
      • The Unit of Work
  • Installation and Configuration
    • Class loading
    • Obtaining an EntityManager
    • Setting up the Commandline Tool
  • Frequently Asked Questions
    • Database Schema
      • How do I set the charset and collation for MySQL tables?
    • Mapping
      • Why do I get exceptions about unique constraint failures during $em->flush()?
    • Associations
      • What is wrong when I get an InvalidArgumentException "A new entity was found through the relationship.."?
      • How can I filter an association?
      • I call clear() on a One-To-Many collection but the entities are not deleted
      • How can I add columns to a many-to-many table?
      • How can i paginate fetch-joined collections?
      • Why does pagination not work correctly with fetch joins?
    • Inheritance
      • Can I use Inheritance with Doctrine ORM?
      • Why does Doctrine not create proxy objects for my inheritance hierarchy?
    • EntityGenerator
      • Why does the EntityGenerator not do X?
      • Why does the EntityGenerator not generate inheritance correctly?
    • Performance
      • Why is an extra SQL query executed every time I fetch an entity with a one-to-one relation?
    • Doctrine Query Language
      • What is DQL?
      • Can I sort by a function (for example ORDER BY RAND()) in DQL?
      • Is it better to write DQL or to generate it with the query builder?
    • A Query fails, how can I debug it?
  • Basic Mapping
    • Creating Classes for the Database
    • Property Mapping
      • Specifying default values
        • PHP Types Mapping
    • Property Hooks
    • Mapping PHP Enums
      • Defining an Enum
      • Single-Value Columns
      • Storing Collections of Enums
      • Nullable Enums
      • Default Values
      • Using Enums in Queries
      • XML Mapping
    • Doctrine Mapping Types
    • Identifiers / Primary Keys
      • Identifier Generation Strategies
        • Sequence Generator
      • Composite Keys
    • Quoting Reserved Words
  • Association Mapping
    • Composite Foreign Keys
    • Many-To-One, Unidirectional
    • One-To-One, Unidirectional
    • One-To-One, Bidirectional
    • One-To-One, Self-referencing
    • One-To-Many, Bidirectional
    • One-To-Many, Unidirectional with Join Table
    • One-To-Many, Self-referencing
    • Many-To-Many, Unidirectional
    • Many-To-Many, Bidirectional
      • Owning and Inverse Side on a ManyToMany Association
    • Many-To-Many, Self-referencing
    • Mapping Defaults
    • Collections
    • Initializing Collections
  • Inheritance Mapping
    • Mapped Superclasses
    • Entity Inheritance
      • Performance impact on to-one associations
    • Single Table Inheritance
      • Design-time considerations
      • Performance impact
      • SQL Schema considerations
    • Class Table Inheritance
      • Design-time considerations
      • Performance impact
      • SQL Schema considerations
    • Overrides
      • Association Override
      • Attribute Override
    • Query the Type
  • Working with Objects
    • Entities and the Identity Map
    • Entity Object Graph Traversal
    • Persisting entities
    • Removing entities
      • Join-table management when removing from many-to-many collections
      • Performance of different deletion strategies
    • Detaching entities
    • Synchronization with the Database
      • Effects of Database and UnitOfWork being Out-Of-Sync
      • Synchronizing New and Managed Entities
      • Synchronizing Removed Entities
      • The size of a Unit of Work
      • The cost of flushing
      • Direct access to a Unit of Work
      • Entity State
    • Querying
      • By Primary Key
      • By Simple Conditions
      • By Criteria
      • By Eager Loading
      • By Lazy Loading
      • By DQL
      • By Native Queries
      • Custom Repositories
  • Working with Associations
    • Association Example Entities
    • Establishing Associations
    • Removing Associations
    • Association Management Methods
    • Synchronizing Bidirectional Collections
    • Transitive persistence / Cascade Operations
      • Persistence by Reachability: Cascade Persist
    • Orphan Removal
    • Filtering Collections
  • Implementing a TypedFieldMapper
    • DefaultTypedFieldMapper
    • TypedFieldMapper interface
    • ChainTypedFieldMapper
    • Implementing a TypedFieldMapper
  • Events
    • The Event System
      • Registering Event Handlers
    • Events Overview
      • Naming convention
    • Lifecycle Callbacks
    • Lifecycle Callbacks Event Argument
    • Listening and subscribing to Lifecycle Events
    • Implementing Event Listeners
      • prePersist
      • preRemove
      • preFlush
      • onFlush
      • postFlush
      • preUpdate
      • postUpdate, postRemove, postPersist
      • postLoad
      • onClear
    • Entity listeners
      • Entity listeners class
      • Entity listeners resolver
    • Load ClassMetadata Event
    • SchemaTool Events
      • postGenerateSchemaTable
      • postGenerateSchema
  • Doctrine Internals explained
    • How Doctrine keeps track of Objects
    • How Doctrine Detects Changes
    • Query Internals
    • The different ORM Layers
      • Hydration
      • Persisters
      • UnitOfWork
      • ResultSetMapping
      • DQL Parser
      • SQLWalker
      • EntityManager
      • ClassMetadataFactory
  • Association Updates: Owning Side and Inverse Side
    • Bidirectional Associations
    • Important concepts
  • Transactions and Concurrency
    • Transaction Demarcation
      • Approach 1: Implicitly
      • Approach 2: Explicitly
      • Exception Handling
    • Locking Support
      • Optimistic Locking
        • Important Implementation Notes
      • Pessimistic Locking
  • Batch Processing
    • Bulk Inserts
    • Bulk Updates
      • DQL UPDATE
      • Iterating results
    • Bulk Deletes
      • DQL DELETE
      • Iterating results
    • Iterating Large Results for Data-Processing
  • Doctrine Query Language
    • Types of DQL queries
    • SELECT queries
      • DQL SELECT clause
      • Result format
      • Joins
      • Named and Positional Parameters
      • DQL SELECT Examples
        • Partial Hydration Syntax
        • "NEW" Operator Syntax
      • Using INDEX BY
    • UPDATE queries
    • DELETE queries
    • Comments in queries
    • Functions, Operators, Aggregates
      • DQL Functions
      • Arithmetic operators
      • Aggregate Functions
      • Other Expressions
      • Adding your own functions to the DQL language
    • Querying Inherited Classes
      • Single Table
      • Class Table Inheritance
    • The Query class
      • Query Result Formats (Hydration Modes)
        • getResult()
        • getArrayResult()
        • getScalarResult()
        • getSingleScalarResult()
        • getSingleColumnResult()
        • getSingleResult()
        • getOneOrNullResult()
        • Custom Hydration Modes
      • Pure and Mixed Results
      • Fetching Multiple FROM Entities
      • Iterating Large Result Sets
      • Functions
        • Parameters
        • Cache related API
        • Query Hints
        • Query Cache (DQL Query Only)
        • First and Max Result Items (DQL Query Only)
        • Temporarily change fetch mode in DQL
    • EBNF
      • Document syntax:
      • Terminals
      • Query Language
      • Statements
      • Identifiers
      • Path Expressions
      • Clauses
      • Items
      • From, Join and Index by
      • Select Expressions
      • Conditional Expressions
      • Collection Expressions
      • Literal Values
      • Input Parameter
      • Arithmetic Expressions
      • Scalar and Type Expressions
      • Aggregate Expressions
      • Case Expressions
      • Other Expressions
      • Functions
  • The QueryBuilder
    • Constructing a new QueryBuilder object
    • Working with QueryBuilder
      • High level API methods
      • Binding parameters to your query
      • Limiting the Result
      • Executing a Query
      • The Expr class
      • Adding a Criteria to a Query
      • Adding hints to a Query
      • Low Level API
      • Expr* classes
        • Binding Parameters to Placeholders
  • Native SQL
    • The NativeQuery class
    • ResultSetMappingBuilder
    • The ResultSetMapping
      • Entity results
      • Joined entity results
      • Field results
      • Scalar results
        • Special case: DTOs
      • Meta results
      • Discriminator Column
      • Examples
  • Change Tracking Policies
    • Deferred Implicit
    • Deferred Explicit
  • Partial Hydration
  • Partial Objects
    • What is the problem?
    • When should I force partial objects?
    • Transparent lazy loading on PHP 8.4
  • Attributes Reference
    • Index
    • Reference
      • #[AssociationOverride]
      • #[AttributeOverride]
      • #[Column]
      • #[Cache]
      • #[ChangeTrackingPolicy]
      • #[CustomIdGenerator]
      • #[DiscriminatorColumn]
      • #[DiscriminatorMap]
      • #[Embeddable]
      • #[Embedded]
      • #[Entity]
      • #[GeneratedValue]
      • #[HasLifecycleCallbacks]
      • #[Index]
      • #[Id]
      • #[InheritanceType]
      • #[JoinColumn], #[InverseJoinColumn]
      • #[JoinTable]
      • #[ManyToOne]
      • #[ManyToMany]
      • #[MappedSuperclass]
      • #[OneToOne]
      • #[OneToMany]
      • #[OrderBy]
      • #[PostLoad]
      • #[PostPersist]
      • #[PostRemove]
      • #[PostUpdate]
      • #[PrePersist]
      • #[PreRemove]
      • #[PreUpdate]
      • #[SequenceGenerator]
      • #[Table]
      • #[UniqueConstraint]
      • #[Version]
  • XML Mapping
    • Simplified XML Driver
      • Example
      • XML-Element Reference
    • Defining an Entity
    • Defining Fields
    • Defining Identity and Generator Strategies
    • Defining a Mapped Superclass
    • Defining Inheritance Mappings
    • Defining Lifecycle Callbacks
    • Defining One-To-One Relations
    • Defining Many-To-One Associations
    • Defining One-To-Many Associations
    • Defining Many-To-Many Associations
    • Cascade Element
    • Join Column Element
    • Defining Order of To-Many Associations
    • Defining Indexes or Unique Constraints
    • Derived Entities ID syntax
  • PHP Mapping
    • Static Function
    • ClassMetadataBuilder
    • ClassMetadata API
      • General Setters
      • Inheritance Setters
      • Field Mapping Setters
      • Lifecycle Callback Setters
      • Versioning Setters
      • General Getters
      • Identifier Getters
      • Inheritance Getters
      • Change Tracking Getters
      • Field & Association Getters
      • Lifecycle Callback Getters
      • Runtime reflection methods
  • Caching
    • Types of Caches
      • Query Cache
      • Result Cache
      • Metadata Cache
    • Clearing the Cache
    • Cache Chaining
    • Cache Slams
  • Improving Performance
    • Bytecode Cache
    • Metadata and Query caches
    • Alternative Query Result Formats
    • Read-Only Entities
    • Extra-Lazy Collections
    • Temporarily change fetch mode in DQL
    • Apply Best Practices
    • Change Tracking policies
  • Tools
    • Doctrine Console
      • Setting Up the Console
      • Display Help Information
      • Command Overview
    • Database Schema Generation
    • Runtime vs Development Mapping Validation
    • Adding own commands
    • Re-use console application
  • Metadata Drivers
    • Core Metadata Drivers
    • Implementing Metadata Drivers
    • ClassMetadata
    • Getting ClassMetadata Instances
  • Best Practices
    • Constrain relationships as much as possible
    • Avoid composite keys
    • Use events judiciously
    • Use cascades judiciously
    • Don't use special characters
    • Don't use identifier quoting
    • Initialize collections in the constructor
    • Don't map foreign keys to fields in an entity
    • Use explicit transaction demarcation
  • Limitations and Known Issues
    • Current Limitations
      • Join-Columns with non-primary keys
      • Mapping Arrays to a Join Table
      • Custom Persisters
      • Persist Keys of Collections
      • Mapping many tables to one entity
      • Behaviors
      • Nested Set
      • Using Traits in Entity Classes
      • Mapping multiple private fields of the same name
    • Known Issues
      • Identifier Quoting and Legacy Databases
      • Microsoft SQL Server and Doctrine "datetime"
      • MySQL with MyISAM tables
  • Pagination
    • Offset-Based Pagination
      • How Offset Pagination Works
      • API Reference
      • Legacy Paginator
    • Cursor-Based Pagination
      • Constructor
      • Basic Usage
      • Navigating Pages
      • API Reference
      • HTML Template Example
      • Cursor Encoding
      • Limitations
    • Writing Strategy-Agnostic Code
  • Filters
    • Example filter class
    • Configuration
    • Disabling/Enabling Filters and Setting Parameters
    • Suspending/Restoring Filters
  • Implementing a NamingStrategy
    • Configuring a naming strategy
    • Underscore naming strategy
    • Naming strategy interface
    • Implementing a naming strategy
  • Advanced Configuration
    • Doctrine and Caching
    • Configuration Options
      • Native Lazy Objects (OPTIONAL)
      • Proxy Directory
      • Proxy Namespace
      • Metadata Driver (REQUIRED)
      • Metadata Cache (RECOMMENDED)
      • Default String Type Schema Length (OPTIONAL)
      • Query Cache (RECOMMENDED)
      • SQL Logger (Optional)
      • Auto-generating Proxy Classes (OPTIONAL)
    • Development vs Production Configuration
    • Connection
    • Proxy Objects
      • Reference Proxies
      • Association proxies
      • Generating Proxy classes
    • Autoloading Proxies
    • Multiple Metadata Sources
    • Default Repository (OPTIONAL)
    • Ignoring entities (OPTIONAL)
    • Setting up the Console
  • The Second Level Cache
    • Caching Regions
    • Cache Regions
      • Cache region
      • Concurrent cache region
      • Timestamp region
    • Caching mode
      • Built-in cached persisters
    • Configuration
      • Enable Second Level Cache
      • Cache Factory
      • Region Lifetime
      • Cache Log
    • Entity cache definition
    • Association cache definition
      • Cache usage
    • Using the query cache
      • Cache mode
      • DELETE / UPDATE queries
    • Using the repository query cache
    • Cache API
    • Limitations
      • Composite primary key
      • Distributed environments
      • Paginator
  • Security
    • User input and Doctrine ORM
    • Preventing Mass Assignment Vulnerabilities

Cookbook

  • Aggregate Fields
    • An example model
    • Using DQL
    • Using your Domain Model
    • Using an Aggregate Field
    • Tackling Race Conditions with Aggregate Fields
    • Keeping Updates and Deletes in Sync
    • Conclusion
  • Custom Mapping Types
  • Persisting the Decorator Pattern
    • Component
    • ConcreteComponent
    • Decorator
    • ConcreteDecorator
    • Examples
  • Extending DQL in Doctrine ORM: Custom AST Walkers
    • Generic count query for pagination
    • Modify the Output Walker to generate Vendor specific SQL
    • Modifying the Output Walker to get the raw SQL with interpolated parameters
  • DQL User Defined Functions
    • Registering your own DQL functions
    • Date Diff
    • Date Add
    • Typed functions
    • Conclusion
  • Generated Columns
    • Declaring a generated column
    • Advanced example: Extracting a value from a JSON structure
  • Implementing ArrayAccess for Domain Objects
    • Option 1
    • Option 2
    • Read-only
  • Keeping your Modules independent
    • Background
    • Set up
    • Final Thoughts
  • SQL-Table Prefixes
    • Implementing the listener
    • Telling the EntityManager about our listener
  • Strategy-Pattern
    • Scenario / Problem
    • Solution
  • Validation of Entities
  • Working with DateTime Instances
    • DateTime changes are detected by Reference
    • Default Timezone Gotcha
    • Handling different Timezones with the DateTime Type
  • Mysql Enums
    • Solution 1: Mapping to Varchars
    • Solution 2: Defining a Type
  • Advanced field value conversion using custom mapping types
    • The entity
    • The mapping type
    • Example usage
  • Entities in the Session
    • Updating an entity
      • Working with scalars
      • Working with custom data transfer objects
    • Serializing entity into the session
  1. Projects
  2. ORM
  3. Documentation
  4. Pagination
3.7.1
Maintained
4.0 (upcoming) 3.8 (upcoming) 3.7.1 (current) 2.21 (upcoming) 2.20.13
Unmaintained
3.6 2.19 2.15 2.14

Pagination

Doctrine ORM provides two pagination strategies for DQL queries. Both handle the low-level SQL plumbing, but they make different trade-offs:

Feature OffsetPaginator CursorPaginator
Total count Yes (extra query) Yes (extra query)
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 random access 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. A total count is also available via getTotalCount(), at the cost of an extra COUNT query.

Offset-Based Pagination

Doctrine ORM ships with the OffsetPaginator for offset-based pagination of DQL queries. The query and the pagination position — a Window value object, which carries both a first result and a page size — are both passed to paginate(), which returns an immutable, iterable WindowPage. This mirrors the CursorPaginator API.

<?phpuse Doctrine\ORM\Tools\Pagination\OffsetPaginator;use Doctrine\ORM\Tools\Pagination\Window;$dql   = 'SELECT p, c FROM BlogPost p JOIN p.comments c ORDER BY p.id ASC';$query = $entityManager->createQuery($dql);// new Window($firstResult, $maxResults), or Window::fromPageNumberAndSize($pageNumber, $pageSize)$page = (new OffsetPaginator())->paginate($query, Window::fromPageNumberAndSize(1, 25));echo $page->getTotalCount() . " result(s), page {$page->getPageNumber()} of {$page->getPageCount()}\n";foreach ($page as $post) {    echo $post->getHeadline() . "\n";}if ($page->hasNextPage()) {    $nextWindow = $page->getNextWindow(); // Window for the next page}

The paginator itself holds no query and no position, only configuration: a single instance is stateless and can be reused — or registered as a service — for any query and any page.

<?php$paginator = new OffsetPaginator();$firstPage  = $paginator->paginate($query, Window::fromPageNumberAndSize(1, 25));$secondPage = $paginator->paginate($query, $firstPage->getNextWindow());

Because the returned WindowPage is immutable and carries no temporal coupling, its accessors can be called in any order, and building another page never affects a page you already hold.

How Offset Pagination Works

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 paginator does the following steps to compute the correct result:

  • Perform a Count query using DISTINCT keyword.
  • Perform a Limit Subquery with DISTINCT to 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 it by setting the fetchJoinCollection constructor argument to false; in that case only 2 instead of the 3 queries described are executed.

fetchJoinCollection set to true might affect results if you use aggregations in your query.

Alternatively, the PaginatorInterface::HINT_ENABLE_DISTINCT query hint instructs Doctrine that the query will not produce "duplicate" rows (only to-one relations are joined), so the DISTINCT keyword is omitted, which can bring important performance improvements:

<?phpuse Doctrine\ORM\Tools\Pagination\OffsetPaginator;use Doctrine\ORM\Tools\Pagination\PaginatorInterface;use Doctrine\ORM\Tools\Pagination\Window;$dql   = 'SELECT u, p FROM User u JOIN u.mainPicture p ORDER BY u.id ASC';$query = $entityManager->createQuery($dql)                       ->setHint(PaginatorInterface::HINT_ENABLE_DISTINCT, false);$page = (new OffsetPaginator())->paginate($query, new Window(0, 100));

API Reference

OffsetPaginator::paginate(Query|QueryBuilder $query, Window $position): WindowPage
Executes the query for the given Window and returns an immutable WindowPage. All page accessors below live on the returned page. Throws an InvalidArgumentException if $position is not a Window.
WindowPage::getItems(): array
Returns the raw entity array for the current page.
WindowPage::count(): int
Returns the number of items on the current page (SPL Countable).
WindowPage::getTotalCount(): int
Returns the total number of matching root entities, ignoring the window.
WindowPage::getPageNumber(): int / WindowPage::getPageCount(): int
Return the 1-based number of the current page and the total number of pages. getPageCount() is at least 1, even for an empty result set.
WindowPage::hasNextPage(): bool / WindowPage::hasPreviousPage(): bool
Return whether a next / previous page is available.
WindowPage::hasToPaginate(): bool
Returns whether the result set spans more than one page.
WindowPage::getNextWindow(): Window / WindowPage::getPreviousWindow(): Window
Return the Window for the next / previous page. Throw a LogicException if there is none — call hasNextPage() / hasPreviousPage() first.
WindowPage::getLastWindow(): Window
Returns the Window of the last page, keeping the same page size. Unlike the two methods above, it never throws: an empty result set has a single, empty first page.
WindowPage::getWindow(): Window
Returns the Window that produced this page.
Window::fromPageNumberAndSize(int $pageNumber, int $pageSize): Window
Builds a Window from a 1-based page number and a page size.
Window::getPageNumber(): int
Returns the 1-based page number the window points at.

Legacy Paginator

The legacy Paginator reads the offset implicitly from the query (setFirstResult() / setMaxResults()) 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";}

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.

Constructor

<?phpnew CursorPaginator(    int $limit,    bool $queryProducesDuplicates = true,)

The paginator only holds configuration: the query and the cursor are passed to paginate(), which returns an immutable CursorPage — symmetric with the offset-based OffsetPaginator. A single instance is stateless and can be reused, or registered as a service, for any query and any page.

$limit
The maximum number of results per page. Unlike Window, which carries its own page size, a Cursor is a pure position: the page size is paginator configuration and therefore never comes from user input.
$queryProducesDuplicates
Set to true (default) when the query joins a to-many collection. The paginator then uses a two-query strategy (ID subquery + WHERE IN) to return the correct number of root entities despite duplicate rows. Set to false when only to-one joins are present — this avoids the subquery overhead and is equivalent to passing fetchJoinCollection: false to the OffsetPaginator. However, passing false on a query that joins a to-many relation is not detected — arbitrary joins can produce duplicate root entities silently, leading to a corrupt result set.

Basic Usage

The $position parameter of paginate() accepts either an encoded string produced by a previous call to getNextCursorAsString() or getPreviousCursorAsString(), or a Cursor instance returned by getNextCursor() or getPreviousCursor(). 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\Pagination\CursorPaginator;$dql = 'SELECT p FROM BlogPost p ORDER BY p.createdAt DESC, p.id DESC';$query = $entityManager->createQuery($dql);$paginator = new CursorPaginator(limit: 15);$page      = $paginator->paginate($query, $cursor);foreach ($page as $post) {    echo $post->getTitle() . "\n";}echo $page->getPreviousCursorAsString(); // previous encoded cursor stringecho $page->getNextCursorAsString();     // next encoded cursor string

Navigating Pages

Pass the encoded cursor back on subsequent requests to move forward or backward, reusing the same paginator:

<?php// Next page$page = $paginator->paginate($query, $nextCursor);// Previous page$page = $paginator->paginate($query, $previousCursor);

The cursor is an encoded string containing the location at which the next query should begin fetching results, along with the navigation direction.

API Reference

CursorPaginator::paginate(Query|QueryBuilder $query, Cursor|string|null $position): CursorPage
Executes the query for the given cursor and returns an immutable CursorPage. Fetches $limit + 1 rows to detect whether a further page exists, then trims the extra row. All accessors below live on the returned CursorPage.
CursorPage::getNextCursor(): Cursor
Returns the Cursor object for the next page. Throws a LogicException if there is no next page — call hasNextPage() first.
CursorPage::getPreviousCursor(): Cursor
Returns the Cursor object for the previous page. Throws a LogicException if there is no previous page — call hasPreviousPage() first.
CursorPage::getNextCursorAsString(): string
Returns the encoded cursor to retrieve the next page. Throws a LogicException if there is no next page — call hasNextPage() first.
CursorPage::getPreviousCursorAsString(): string
Returns the encoded cursor to retrieve the previous page. Throws a LogicException if there is no previous page — call hasPreviousPage() first.
CursorPage::hasNextPage(): bool
Returns whether a next page is available.
CursorPage::hasPreviousPage(): bool
Returns whether a previous page is available.
CursorPage::hasToPaginate(): bool
Returns whether either a next or previous page exists (i.e. the result set spans more than one page).
CursorPage::getItems(): array
Returns the raw entity array for the current page.
CursorPage::getItemsWithCursors(): array
Returns an array of CursorItem objects, each wrapping an entity and its individual Cursor. Useful when you need per-row cursors.
CursorPage::getCursorForItem(mixed $item, bool $isNext = true): Cursor
Builds a Cursor pointing at a specific entity. $isNext = true means "start after this item"; false means "start before this item".
CursorPage::count(): int
Returns the number of items on the current page.
CursorPage::getTotalCount(): int
Executes an extra COUNT query and returns the total number of matching root entities, ignoring the cursor and limit. Use this when you need to display a total result count alongside previous/next navigation.

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\Pagination\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(limit: 15);$page = $paginator->paginate($query, $cursor);?><p><?= $page->getTotalCount() ?> result(s) in total, <?= $page->count() ?> on this page.</p><ul>    <?php foreach ($page as $post): ?>        <li><?= escape($post->getTitle()) ?></li>    <?php endforeach ?></ul><?php if ($page->hasToPaginate()): ?>    <nav>        <?php if ($page->hasPreviousPage()): ?>            <a href="?cursor=<?= escape($page->getPreviousCursorAsString()) ?>">Previous</a>        <?php endif ?>        <?php if ($page->hasNextPage()): ?>            <a href="?cursor=<?= escape($page->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 BY column must map to an entity field. Raw SQL expressions or computed columns in ORDER BY are not supported.
  • The query must have at least one ORDER BY item; the paginator throws a LogicException otherwise.

Writing Strategy-Agnostic Code

Both strategies share two interfaces, so code that only lists results can be written once and work with either one:

PaginatorInterface<T, TPosition>
Implemented by OffsetPaginator (a PaginatorInterface<T, Window>) and CursorPaginator (a PaginatorInterface<T, Cursor|string|null>). Its single method is paginate(Query|QueryBuilder $query, mixed $position = null): Page. The native type of $position is mixed because the position type is strategy specific; the TPosition template parameter narrows it for static analysis.
Page<T>
Implemented by WindowPage and CursorPage. It extends Countable and IteratorAggregate and exposes getItems(), getTotalCount(), hasPreviousPage(), hasNextPage() and hasToPaginate().

Navigating to another page is deliberately left out of Page, since the position types differ: WindowPage::getNextWindow() returns a Window, CursorPage::getNextCursor() a Cursor.

<?phpuse Doctrine\ORM\Query;use Doctrine\ORM\Tools\Pagination\PaginatorInterface;function renderTitles(PaginatorInterface $paginator, Query $query, mixed $position): void{    $page = $paginator->paginate($query, $position);    echo $page->getTotalCount() . " result(s), " . count($page) . " on this page\n";    foreach ($page as $post) {        echo $post->getTitle() . "\n";    }}