timefrontiers / php-pagination
PHP pagination trait for database objects and collections
Requires
- php: >=8.5
Requires (Dev)
- php-parallel-lint/php-parallel-lint: ^1.4
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- timefrontiers/php-database-object: ^1.1.1
- timefrontiers/php-multiform: 1.1.1
This package is auto-updated.
Last update: 2026-08-30 18:35:59 UTC
README
Bounded, overflow-safe offset pagination for PHP 8.5 and later. The package provides a transport-neutral calculation trait, strict input DTO, optional GET-only HTTP adapter, and explicit-query URL builder.
Use offset pagination for bounded customer/admin lists. Long-running workers, outbox consumers, migrations, renewals, dunning, and other mutable scans should use keyset/cursor pagination instead.
Installation
composer require timefrontiers/php-pagination:^1.1
Quick start
use TimeFrontiers\Helper\Pagination; final class UserList { use Pagination; } $pager = (new UserList()) ->fromArray($validatedQuery, max_per_page: 100) ->setTotalCount($repository->countUsers()); $rows = $repository->findUsers( limit: $pager->limit(), offset: $pager->offset(), ); $meta = $pager->paginationMeta( '/users#results', 'page', ['status' => $validatedStatus], );
limitClause() remains available for compatibility with reviewed SQL strings,
but limit()/offset() or limitOffset() are preferred for prepared query
APIs.
Strict input
The core trait never reads $_GET, $_POST, $_SERVER, or another request
global. Apply an explicit input map or DTO:
$pager->fromArray( ['page' => '3', 'per_page' => '25'], max_per_page: 100, ); use TimeFrontiers\Helper\PaginationInput; $input = PaginationInput::fromValues(page: 3, perPage: 25, maxPerPage: 100); $pager->applyPagination($input);
PaginationInput::fromArray() accepts positive integers and canonical decimal
integer strings. It rejects zero, negatives, leading zeros, decimals,
exponents, signs, whitespace, booleans, arrays, nulls, and values beyond the
platform integer range. Page and page-size parameter names must begin with an
ASCII letter, contain only letters, digits, _, ., or -, and be at most 64
characters.
The default maximum page size is 1000 for 1.0 compatibility. Applications
should choose a lower policy with max_per_page or setMaxPerPage(). A page
size outside that maximum is rejected. Lowering the maximum clamps the current
page size to the new policy.
Optional HTTP adapter
HTTP parsing is isolated at the edge:
use TimeFrontiers\Helper\PaginationHttpAdapter; $input = PaginationHttpAdapter::fromGlobals(maxPerPage: 100); $pager->applyPagination($input);
fromGlobals() reads query ($_GET) values only. It never falls back to POST.
fromQuery($query) is preferable when a controller already owns a normalized
query map.
Page boundaries
The requested and effective pages are separate:
$pager->setPerPage(20)->setPage(10)->setTotalCount(95); $pager->requestedPage(); // 10 $pager->currentPage(); // 5 (clamped to the last page) $pager->wasPageClamped(); // true $pager->isRequestedPageValid();// false $pager->itemRange(); // [81, 95]
This lets controllers decide whether to serve the clamped page, redirect, or return 404 without losing the original request.
An empty collection has zero total pages and a virtual current page of 1:
currentPage() 1
requestedPage() retained
totalPages() 0
previousPage() 1
nextPage() 1
itemRange() [0, 0]
isFirstPage() true
isLastPage() true
hasPreviousPage() false
hasNextPage() false
Only page 1 is valid for the empty virtual state. Requesting a later page is retained but clamped to 1.
Overflow and range bounds
All total-page, offset, and item-boundary calculations use checked integer
arithmetic. Inputs that would make either the offset or the complete page
window exceed PHP_INT_MAX throw OverflowException before arithmetic.
Negative totals and invalid configuration throw InvalidArgumentException.
UI ranges are bounded:
$pager->setPerPage(1)->setPage(5)->setTotalCount(20); $pager->pageRange(2); // [1, null, 3, 4, 5, 6, 7, null, 20] $pager->pages(); // all pages only when totalPages() <= 1000 $pager->pages(250); // caller-selected lower materialization bound
pageRange() rejects side values outside 0..48, so it returns at most 101
entries. pages() accepts an explicit maximum from 1 through 1000 and throws
instead of materializing beyond that hard safety cap.
Explicit safe links
Link construction never copies the request query. Pass an allowlisted map:
$url = $pager->pageUrl( 3, '/users?sort=name#results', 'p', ['status' => 'active', 'search' => 'Ada Lovelace'], ); // /users?sort=name&status=active&search=Ada%20Lovelace&p=3#results
Existing base queries are merged, the target page overwrites an existing page
key, query values use RFC 3986 encoding, and fragments remain last. Explicit
query values must be scalar or null; nested structures and invalid keys are
rejected. With no base, the result is query-relative (?page=3).
Credentials, signatures, reset tokens, CSRF values, and other sensitive parameters are absent by default because no global query is copied. Do not add them to the explicit map or base URL. URL generation is not HTML encoding; escape the final URL for the actual output context.
previousPageUrl(), nextPageUrl(), and paginationMeta() accept the same
explicit base, page key, and allowlisted query map.
API reference
Configuration
| Method | Purpose |
|---|---|
applyPagination(PaginationInput) |
Atomically apply validated page, size, and maximum |
fromArray(array, ...) |
Parse an explicit transport-neutral input map |
setPage(int) |
Retain a positive requested page and synchronize the effective page |
setPerPage(int) |
Set a positive page size within the configured maximum |
setMaxPerPage(int) |
Configure the page-size policy |
setTotalCount(int) |
Set a non-negative total and synchronize boundaries |
State and calculations
| Method | Return |
|---|---|
requestedPage() / currentPage() |
Original and effective 1-based page |
wasPageClamped() / isRequestedPageValid() |
Boundary decision metadata |
perPage() / limit() / maxPerPage() |
Page-size values |
totalCount() / totalPages() |
Total item/page counts |
offset() / limitOffset() / limitClause() |
Query bounds |
itemStart() / itemEnd() / itemRange() |
Current 1-based item range, or zeroes when empty |
previousPage() / nextPage() |
Bounded navigation page |
hasPreviousPage() / hasNextPage() |
Navigation availability |
isFirstPage() / isLastPage() / isValidPage() |
Page-state checks |
pageRange() / pages() |
Bounded UI materialization |
paginationToArray() / paginationMeta() |
API/UI metadata, optionally with explicit links |
Trait composition
Version 1.1 composes with released DatabaseObject 1.1.1 and Multiform 1.1.1. Canonical protected trait state is deliberately prefixed:
$_pagination_requested_page
$_pagination_current_page
$_pagination_per_page
$_pagination_max_per_page
$_pagination_total_count
The deprecated $_current_page, $_per_page, and $_total_count aliases are
synchronized for released 1.x consumers such as Multiform 1.1.1. They are
read-only by convention and must not be accessed by new consumers. Private
synchronization helpers use the _pagination... prefix.
MultiformQuery's own limit(int)/offset(int) builder methods continue to
override the trait names, while its existing paginationOffset() alias remains
available internally. Its direct paginationToArray() override retains the
released 1.0-shaped array; inherited paginationMeta() always adds the 1.1
requested-page and clamp fields without dispatching through that override.
Upgrading from 1.0
- Replace trait
fromRequest()withfromArray()or applyPaginationHttpAdapter::fromGlobals()at the HTTP boundary. - Pass explicit allowlisted query state to URL helpers; request globals are no longer copied.
- Handle
InvalidArgumentException,OverflowException, andLengthExceptionas configuration/input-policy failures. - Use
requestedPage()andwasPageClamped()for redirect/404 decisions. - Expect strict rejection instead of silent clamping for invalid page, page
size, and total values. Lowering
setMaxPerPage()still clamps the existing size to the selected policy. - Migrate any subclass that accesses
_current_page,_per_page, or_total_countdirectly. Version 1.1 synchronizes those deprecated aliases for released-consumer compatibility, but public methods are the supported contract.
Development
composer validate --strict --no-check-publish composer update composer audit --locked composer check
The repository does not commit composer.lock. CI resolves highest and lowest
supported dependencies on PHP 8.5 and compiles the released DatabaseObject and
Multiform compositions.
License
MIT