Pop PHP
The Toolkit

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.

BASH
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:

PHP
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:

PHP
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.

PHP
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:

PHP
use Pop\Paginator\Paginator;

echo Paginator::createRange(42);
HTML
<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:

PHP
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>:

PHP
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:

PHP
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:

PHP
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:

PHP
use Pop\Paginator\Paginator;

$paginator = Paginator::createRange(4512, 10, 10);

echo implode("\n", $paginator->getLinkRange(12));
HTML
<a href="/posts?page=1">&laquo;</a>
<a href="/posts?page=10">&lsaquo;</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">&rsaquo;</a>
<a href="/posts?page=452">&raquo;</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:

PHP
use Pop\Paginator\Paginator;

$paginator = Paginator::createRange(4512, 10, 10);

$paginator->setBookends(['start' => null, 'end' => null]);

$paginator->getBookend('next');  // '&rsaquo;'
$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:

PHP
use Pop\Paginator\Paginator;

$paginator = Paginator::createForm(558);

echo $paginator->getFormString(14);
HTML
<a href="/posts?page=1">&laquo;</a><a href="/posts?page=13">&lsaquo;</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">&rsaquo;</a><a href="/posts?page=56">&raquo;</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#