timefrontiers/php-pagination

PHP pagination trait for database objects and collections

Maintainers

Package info

github.com/timefrontiers/php-pagination

pkg:composer/timefrontiers/php-pagination

Transparency log

Statistics

Installs: 84

Dependents: 4

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-08-30 18:34 UTC

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

  1. Replace trait fromRequest() with fromArray() or apply PaginationHttpAdapter::fromGlobals() at the HTTP boundary.
  2. Pass explicit allowlisted query state to URL helpers; request globals are no longer copied.
  3. Handle InvalidArgumentException, OverflowException, and LengthException as configuration/input-policy failures.
  4. Use requestedPage() and wasPageClamped() for redirect/404 decisions.
  5. 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.
  6. Migrate any subclass that accesses _current_page, _per_page, or _total_count directly. 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