Skip to content

feat: paginate without counting the collection - #60

Merged
kbond merged 4 commits into
zenstruck:1.xfrom
kbond:feat/count-free-pagination
Aug 20, 2026
Merged

kbond merged 4 commits into
zenstruck:1.xfrom
kbond:feat/count-free-pagination

Conversation

@kbond

@kbond kbond commented Aug 20, 2026

Copy link
Copy Markdown
Member

Refs #51.

Page needed the total to answer even basic questions - nextPage() went through lastPage()totalCount()count(), so a previous/next pager paid for a COUNT over the whole table. It now fetches one item more than fits on the page and uses that extra item's presence to answer the new hasMorePages(), discarding it before you see the items. nextPage(), previousPage() and haveToPaginate() are all derived from it.

totalCount(), lastPage() and pageCount() are unchanged - they still count, but only when called. So a previous/next pager counts nothing, and a numbered one counts once.

Strict mode got the same treatment: it used to count on every request to validate the page number, and now fetches the requested page first and only counts if that page came back empty. A valid page costs nothing extra; the fallback is paid for only when the requested page really is out of range.

For #51 specifically: @jungleman12's exact usage (paginator.nextPage and paginator|length over 2M rows) now runs no COUNT at all, and @seb-jean's diagnosis in that thread was exactly right - the nextPage()lastPage()totalCount() chain is what's gone. What this does not fix is the OFFSET cost you already called out there: deep pages still make the database walk and discard rows. That's still #52's territory, so I left this as a reference rather than a fix.

Also adds Pager/_simple.html.twig and Pager/_full.html.twig for rendering either mode against a plain Page, and Twig becomes a dev dependency so they're actually rendered in tests.

Behavior change: nextPage() returns null for a page past the end where it previously returned the next page number (paginate(page: 999) on an 8-page collection used to report 1000). Two existing assertions covered that and were updated.

kbond added 4 commits August 20, 2026 14:32
`Page` now fetches one item more than fits and uses its presence for `hasMorePages()`, so a previous/next pager costs a single query. `totalCount()`, `lastPage()`, `pageCount()` and `strict()` still count, but only when called.

`nextPage()` now returns `null` for a page past the end rather than the next page number.
Pagination is now documented by what your template asks for rather than as one flat API: `Simple` (previous/next, no counting) and `Full` (totals and page numbers), with strict mode under `Full` where the total is already known.

The cost is described as counting the collection rather than as queries, since what that costs is up to the source - free for an array, a `COUNT` for Doctrine, a full iteration for a generator.
Strict mode counted the collection on every request to validate the page number. It now fetches the requested page first and only counts if that page came back empty - so a valid page costs nothing extra and the fallback is paid for only when it's actually needed.
Renders a previous/next or numbered pager for any `Page`, linking to the current route and keeping its query parameters. Twig is now a dev dependency so the templates are rendered in tests.
@kbond
kbond merged commit d368a27 into zenstruck:1.x Aug 20, 2026
18 checks passed
@kbond
kbond deleted the feat/count-free-pagination branch August 20, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant