Pagination
pop-paginator takes a total number of items and renders the navigation for it. It does not query
anything and does not slice your data — you fetch one page's worth yourself, hand the paginator the
total, and it produces the links. That separation is what lets the same object sit above a database
result, a filesystem listing or an API response.
composer require popphp/pop-paginator
Paginating a Result Set#
Pagination is two numbers and one call. You need the total number of rows and the page the reader is on; everything else follows from those:
use App\Table\Posts;
use Pop\Paginator\Paginator;
$perPage = 10;
$page = 3;
$total = Posts::getTotal();
$posts = Posts::findAll([
'order' => 'id ASC',
'limit' => $perPage,
'offset' => ($page - 1) * $perPage,
]);
$paginator = Paginator::createRange($total, $perPage);
getTotal() counts every row matching the predicate, ignoring limit and offset, which is exactly
the number the paginator wants. The offset arithmetic is yours to write — ($page - 1) * $perPage —
because the paginator never sees your data.
In a controller the page number comes off the request, and it arrives as a string from the query string, so cast it and floor it at one:
use Pop\Http\Server\Request;
$request = new Request();
$page = max(1, (int)($request->getQuery('page') ?? 1));
The paginator reads $_GET on its own when you render it, so this value is for the query, not for the
paginator — but the two have to agree, and computing it once is how you keep them agreeing.
Paginator::createRange() returns a Pop\Paginator\Range, and Paginator::createForm() returns a
Pop\Paginator\Form. Both take the total and an optional per-page count that defaults to 10; Range
takes a third argument, the number of page links to show at once, also defaulting to 10. Reach for both
through the factories rather than constructing Range or Form directly — one entry point covers both
shapes.
The constructor validates its counts before anything renders, throwing Pop\Paginator\Exception for a
negative total, a per-page below one or a range below one. A total of exactly 0 is accepted, reports zero
pages and renders an empty string, so a page with no results needs no special case.
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
$paginator->getTotal(); // 4512
$paginator->getPerPage(); // 10
$paginator->getNumberOfPages(); // 452
$paginator->getCurrentPage(); // 1, before anything has been rendered
getNumberOfPages() is available immediately, which is useful for deciding whether to render
navigation at all.
Rendering Controls#
Casting a paginator to a string renders it. On a request to /posts with 42 items and no page
selected:
use Pop\Paginator\Paginator;
echo Paginator::createRange(42);
<span>1</span><a href="/posts?page=2">2</a><a href="/posts?page=3">3</a><a href="/posts?page=4">4</a><a href="/posts?page=5">5</a>
The current page is a <span>, every other page is an <a>. The links are built from
$_SERVER['REQUEST_URI'] and $_SERVER['QUERY_STRING'], and any other query parameters on the current
request are carried through — paginating /posts?sort=name produces ?page=2&sort=name, so a sort or
filter survives the click.
The current page comes from $_GET['page']. Outside a web request — a test, a CLI script, a queued
job — there's no $_GET to read, so pass the page explicitly. getLinkRange() takes a page number
and returns the links as an array rather than one string:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
foreach ($paginator->getLinkRange(12) as $link) {
echo $link;
}
Form has the matching getFormString(). Both ignore $_GET entirely when given a page.
Clamp the page number against getNumberOfPages() before rendering if you want an out-of-bounds page to
fall back to the first or last.
Three settings shape the markup. setSeparator() joins the links when the object is cast to a string,
and setClassOn()/setClassOff() set the class on the <a> tags and on the current page's <span>:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(42);
$paginator->setSeparator(' | ');
$paginator->setClassOn('page-link');
$paginator->setClassOff('page-current');
echo $paginator;
// <span class="page-current">1</span> | <a class="page-link" href="/posts?page=2">2</a> ...
The separator applies only to the string cast; getLinkRange() returns the same links unjoined. For a
list-based component, wrapLinks() wraps each link in an element and puts the on/off class on the
wrapper instead:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
echo '<ul>' . implode('', $paginator->wrapLinks('li', 'page-link-on', 'page-link-off')) . '</ul>';
// <ul><li class="page-link-off"><span>1</span></li><li class="page-link-on"><a href="/posts?page=2">2</a></li>...
setQueryKey() changes the parameter the paginator both reads and writes, which is what you need when
two things are paginated on one page:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
$paginator->setQueryKey('p');
// links now use ?p=2, and the current page is read from $_GET['p']
Ranges and Bookends#
Four hundred and fifty-two page links do not belong on a page. Range's third argument caps how many
show at once, and the paginator adds bookends to move between blocks. On page 12 of 452, with a range
of 10:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
echo implode("\n", $paginator->getLinkRange(12));
<a href="/posts?page=1">«</a>
<a href="/posts?page=10">‹</a>
<a href="/posts?page=11">11</a>
<span>12</span>
<a href="/posts?page=13">13</a>
<a href="/posts?page=14">14</a>
<a href="/posts?page=15">15</a>
<a href="/posts?page=16">16</a>
<a href="/posts?page=17">17</a>
<a href="/posts?page=18">18</a>
<a href="/posts?page=19">19</a>
<a href="/posts?page=20">20</a>
<a href="/posts?page=21">›</a>
<a href="/posts?page=452">»</a>
The block shown is pages 11 to 20, and the four bookends step by blocks rather than by single pages:
start jumps to page 1, previous to page 10 — the page immediately before the current block — next
to page 21, the page immediately after it, and end to page 452. setBookends() replaces only the keys you pass, and null removes one from the
output:
use Pop\Paginator\Paginator;
$paginator = Paginator::createRange(4512, 10, 10);
$paginator->setBookends(['start' => null, 'end' => null]);
$paginator->getBookend('next'); // '›'
$paginator->getBookends(); // start and end are now null
For a set too large for even a block of links, Form replaces the numbers with an input the reader
types a page into, keeping the bookends around it:
use Pop\Paginator\Paginator;
$paginator = Paginator::createForm(558);
echo $paginator->getFormString(14);
<a href="/posts?page=1">«</a><a href="/posts?page=13">‹</a><form class="pop-paginator-form" action="/posts" method="get"><div><input type="text" name="page" size="2" value="14" /> of 56</div></form><a href="/posts?page=15">›</a><a href="/posts?page=56">»</a>
The input is named after the query key, so setQueryKey() renames it along with the links. of is the
default text between the input and the total, changed with setInputSeparator('/').
Form steps one page at a time rather than by a block, and its separator setting is
setInputSeparator(). setSeparator(), setClassOn() and setClassOff() belong to Range.
Every setting has a getter — getTotal(), getPerPage(), getRange(), getQueryKey(),
getCurrentPage(), getNumberOfPages(), getSeparator(), getClassOn(), getClassOff() and
getInputSeparator() — which is what makes a paginator straightforward to inspect from a template.
The full set of options goes past what this page covers — see the pop-paginator README.
See Also#
- Querying — the
limitandoffsetthat fetch one page's rows - Records & the ORM —
getTotal()and the$optionsarray it shares with the finders - Views & Templates — printing the rendered links from a template
- pop-paginator README — every option and getter