Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5757040
Add strategy-independent pager, position and cursor contracts
mbabker Sep 25, 2026
b0b84b4
Add the cursor pagers, factory, and Base64 JSON cursor encoder
mbabker Sep 25, 2026
717f833
Add the Array, Callback, Transforming, and Empty cursor adapters
mbabker Sep 25, 2026
e61cba1
Add a decorator to make any cursor adapter countable
mbabker Sep 25, 2026
fd8788f
Add cursor pagination adapters for Doctrine ORM 3.7+
mbabker Sep 25, 2026
e4f7738
Add a helper to build cursor slices from lookahead results
mbabker Sep 25, 2026
990ff67
Add a cursor pagination adapter for Doctrine DBAL
mbabker Sep 25, 2026
4058fc8
Add a cursor pagination adapter for Doctrine Collections
mbabker Sep 25, 2026
5bda4ee
Add a cursor pagination adapter for Elastica
mbabker Sep 25, 2026
ab332c4
Add a cursor pagination adapter for Solarium
mbabker Sep 25, 2026
3d47f52
Add a sequential view and position based templates
mbabker Sep 25, 2026
bf60041
Render the sequential view from a copy of its template
mbabker Sep 25, 2026
be1c0d1
Support sequential rendering in Twig
mbabker Sep 25, 2026
68c794d
Docs pass on cursor and sequential
mbabker Sep 25, 2026
de005a2
Deprecate counting a Pagerfanta instance to get the total number of r…
mbabker Sep 25, 2026
e39ca0a
Deprecate the page number based route generator and view APIs
mbabker Sep 25, 2026
602a9ab
Add CHANGELOG entries
mbabker Sep 25, 2026
cc9b45e
Generate page URLs with the position route generator when available
mbabker Sep 28, 2026
a0f8f8a
Remove method overrides now inherited from the feature interfaces
mbabker Oct 5, 2026
5ca8d86
PageNumberRouteGenerator doesn't need to be internal
mbabker Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/.github export-ignore
/adr export-ignore
/bin export-ignore
/docs export-ignore
/lib/**/*/Tests export-ignore
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
- Fix the template views reusing the options from a previous render
- Add support for `ruflin/elastica` 9.x
- [#66](https://github.com/BabDev/Pagerfanta/issues/66) Improved handling of zero-length slices in the pagination adapters
- Add cursor pagination support
- Add support for rendering sequential pagination views (previous/next links only)
- Deprecate the page number based route generator and view APIs

## 4.9.0 (2026-09-08)

Expand Down
37 changes: 37 additions & 0 deletions adr/0001-position-marker-interface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 1. Positions are a marker interface

- Status: Accepted
- Date: 2026-09-25
- Tracking issue: [#30](https://github.com/BabDev/Pagerfanta/issues/30)

## Context

Pagerfanta is adding cursor pagination alongside the existing offset pagination. Route generators and views need to link to
the "previous" and "next" pages without knowing which strategy the pager uses. Offset pagers identify a page by its number,
while cursor pagers identify a page by a cursor holding sort key values and a direction.

The root pager interface therefore needs a strategy-independent way to describe "where" a link points. Two options were
considered:

1. **Marker interface**: an empty `Pagerfanta\Position\Position` interface, implemented by `PagePosition` (a page number)
and `CursorPosition` (a cursor). Pagers return positions, and route generators accept a `Position`.
2. **Generics only**: no shared type at runtime. The root pager declares `@template TPosition` and the position methods return
`mixed` (an `int` for offset pagers, a cursor for cursor pagers).

## Decision

Positions are a marker interface (option 1). The root `PagerInterface` also declares `@template TPosition of Position` so that
static analysis can narrow the position type for the offset (`PagePosition`) and cursor (`CursorPosition`) pagers.

## Consequences

- Route generators and views stay simply typed: `__invoke(Position $position): string` works for every strategy, and
implementations dispatch with `instanceof`.
- Each generated link allocates a small value object. This is negligible compared to rendering the link.
- Offset pagers wrap page numbers in `PagePosition`, so existing `int` based APIs (`getPreviousPage()`, `getNextPage()`,
`RouteGeneratorInterface`) remain in 4.x. `PageRouteGeneratorWrapper` adapts `int` based route generators to the position
based API.
- A generator given a position it does not support (e.g. an `int` based generator given a `CursorPosition`) throws an
`InvalidArgumentException` rather than silently producing a wrong URL.
- The generics-only approach would have pushed `mixed` into every generator and view signature, losing runtime type safety
for code not analyzed with PHPStan or Psalm.
81 changes: 81 additions & 0 deletions docs/adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,84 @@ interface AdapterInterface
public function getSlice(int $offset, int $length): iterable;
}
```

The `AdapterInterface` is composed of two smaller interfaces, which describe the capabilities of an adapter separately:

- `Pagerfanta\Adapter\CountableAdapterInterface`: An adapter which can count the total number of items in the list with `getNbResults`
- `Pagerfanta\Adapter\OffsetAdapterInterface`: An adapter which can retrieve a page of items with `getSlice`, using an offset and a length

## Cursor Adapters

<div class="docs-note docs-note--new-feature">Cursor adapters were introduced in Pagerfanta 4.10.</div>

Pagerfanta defines `Pagerfanta\Adapter\CursorAdapterInterface` which is the abstraction layer for any system to provide data to a [cursor pager](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination).

The interface requires two methods to be implemented:

- `getSlice`: Retrieves the items following (or preceding, based on the direction of the cursor) the given cursor, along with the cursors pointing to the neighboring pages
- A null cursor requests the first page
- The items must always be returned in the sort order of the list, regardless of the direction of the cursor
- An adapter can throw a `Pagerfanta\Exception\InvalidCursorException` if the cursor is not valid for it, such as when the cursor fields do not match the fields the list is sorted by
- `supportsBackwardNavigation`: Reports whether the adapter can paginate backwards, adapters which cannot should never return a cursor for the previous page

A cursor adapter does not count the total number of items in the list unless it also implements `Pagerfanta\Adapter\CountableAdapterInterface`.

```php
<?php

namespace Pagerfanta\Adapter;

use Pagerfanta\Cursor\Cursor;
use Pagerfanta\Exception\InvalidCursorException;

interface CursorAdapterInterface
{
/**
* Returns the slice of results following (or preceding, based on the cursor's direction) the given cursor.
*
* @throws InvalidCursorException if the cursor is not valid for this adapter
*/
public function getSlice(?Cursor $cursor, int $limit): CursorSlice;

/**
* Whether this adapter can paginate backwards (i.e. generate a cursor for the previous page).
*/
public function supportsBackwardNavigation(): bool;
}
```

The `Pagerfanta\Adapter\CursorSlice` returned by `getSlice` holds the items on the page, and the cursors for the previous page (with the `Direction::Previous` direction) and the next page (with the `Direction::Next` direction), which are null when there is no page in that direction.

### Detecting Neighboring Pages

To know whether there is another page without counting the results, an adapter generally fetches one more item than the limit and trims the extra item before returning the slice. The `CursorSlice::fromLookahead()` method implements this approach for adapters which fetch up to `$limit + 1` items in the direction of the cursor (in reverse sort order for a cursor with the previous direction), given a callable which creates the cursor pointing to an item.

```php
<?php

use Pagerfanta\Adapter\CursorAdapterInterface;
use Pagerfanta\Adapter\CursorSlice;
use Pagerfanta\Cursor\Cursor;
use Pagerfanta\Cursor\Direction;

final class PostCursorAdapter implements CursorAdapterInterface
{
public function getSlice(?Cursor $cursor, int $limit): CursorSlice
{
// Fetch up to $limit + 1 posts after the cursor, or before it in reverse order for a cursor with the previous direction
$items = $this->fetchPosts($cursor, $limit + 1);

return CursorSlice::fromLookahead(
$items,
$limit,
$cursor,
static fn (Post $post, Direction $direction): Cursor => new Cursor(['id' => $post->id], $direction),
);
}

public function supportsBackwardNavigation(): bool
{
return true;
}
}
```
Loading
Loading