Pagination
Rhapsody includes a Rhapsody\Core\Pagination class that makes breaking large datasets into navigable pages straightforward, with optional support for custom CSS classes, a custom base URL, windowed page ranges with First/Last links, a jump-to-page menu, AJAX-friendly data attributes, and rendering through your own Twig template.
1. Model Setup
Your model needs two methods: one to count total records, and one to fetch a specific slice using LIMIT and OFFSET.
// app/Models/User.php
public function countAll(): int
{
$stmt = $this->db->query("SELECT COUNT(user_id) FROM users");
return (int) $stmt->fetchColumn();
}
public function findPaginated(int $limit, int $offset): array
{
$sql = "SELECT ... FROM users ... LIMIT :limit OFFSET :offset";
$stmt = $this->db->prepare($sql);
$stmt->bindParam(':limit', $limit, PDO::PARAM_INT);
$stmt->bindParam(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
2. Controller Logic
Instantiate the Pagination class with the total number of items, the desired limit, and the current page.
// app/Controllers/PageController.php
use Rhapsody\Core\Pagination;
use Rhapsody\Core\Request;
public function showUsers(Request $request): Response
{
$usersPerPage = (int) $request->getQueryParam('limit', 10);
$currentPage = (int) $request->getQueryParam('page', 1);
$totalUsers = $this->userModel->countAll();
$pagination = new Pagination($totalUsers, $usersPerPage, $currentPage);
// Optional: add custom CSS classes to the rendered navigation
$pagination->setCssClasses([
'container' => 'pagination-container',
'list' => 'pagination-list',
'item' => 'pagination-item',
'active' => 'is-active',
'disabled' => 'is-disabled',
'link' => 'pagination-link',
]);
$users = $this->userModel->findPaginated(
$pagination->getLimit(),
$pagination->getOffset()
);
return $this->view('home/users.twig', [
'users' => $users,
'pagination' => $pagination,
]);
}
3. Rendering in the View
In your Twig template, call the render() method on the pagination object. It takes no arguments
and returns a ready-to-use HTML string:
<!-- views/home/users.twig -->
<div class="flex justify-center mt-8">
{{ pagination.render()|raw }}
</div>
The rendered markup is a <nav> containing a Previous link, a link for every page number,
and a Next link — with the current page shown as inactive text rather than a link, and Previous/Next shown
as disabled text at the start/end of the range. If there's only one page total, render() returns
an empty string.
4. Customizing Appearance
setCssClasses() accepts any subset of the following keys — anything you don't override keeps its
default value, which matches the framework's original unstyled markup exactly, so calling code that never
touches this method sees no visual change:
container– wraps the whole nav in a<div>with this class (only added if you set a non-empty value)list– class on the<ul>item– class on each<li>link– class on each clickable page linkdisabled– class on the disabled Previous/Next textactive– class on the current page's text
5. Custom Base URL
By default, page links are relative (?page=2), resolving against whatever URL the current page
was requested on. If you're rendering pagination somewhere that URL isn't the right target — for example, a
listing reachable from more than one route — use setBaseUrl() to make links absolute to a
specific path instead:
$pagination->setBaseUrl('/users/list');
// Links become "/users/list?page=2" instead of "?page=2"
6. Preserving Query Parameters
Any query parameters already present on the current request (other than page) are carried
forward onto every page link automatically — for example, if the page was requested with
?limit=25&sort=name, every rendered link will include &limit=25&sort=name
as well. This happens automatically by reading the current request's query string; there's no method to add
parameters that aren't already part of it.
7. Windowed Pages, First & Last Links
By default, render() shows every page number — unchanged from the original behavior. For large
page counts, pass maxLinks (either via setMaxLinks() as a standing default, or as a
one-off render() option) to show a window of pages centered on the current one, with
First/Last links and an ellipsis appearing automatically whenever page 1 or
the last page falls outside that window:
// Standing default — every render() call after this uses a 7-page window
$pagination->setMaxLinks(7);
// One-off override for a single render() call
echo $pagination->render(['maxLinks' => 7]);
// e.g. with 20 total pages, on page 10:
// Previous First … 8 9 [10] 11 12 … Last Next
8. Jump-to-Page Menu
Enable a <select> that lets visitors jump straight to any page, rendered immediately
after the nav:
$pagination->setShowJumpMenu(true);
echo $pagination->render();
It works with no JavaScript — a Go button submits a standard GET form that preserves every
other current query parameter as hidden inputs — and progressively enhances to jump immediately on
<select> change when JavaScript is available. Only simple (string or numeric) query
parameters are carried over; nested array parameters such as ?filter[]=x are skipped because
they can't be represented by a single hidden input. To place the menu somewhere other than
right after the nav, call renderJumpMenu() on its own instead of passing showJumpMenu
to render():
{{ pagination.render()|raw }}
<div class="mt-4">
{{ pagination.renderJumpMenu()|raw }}
</div>
9. AJAX Data Attributes
getDataAttributes() returns an associative array of data-* attributes describing
the current pagination state, for a JavaScript-driven pagination widget to read:
$pagination->getDataAttributes();
// [
// 'data-pagination' => '',
// 'data-current-page' => 3,
// 'data-total-pages' => 12,
// 'data-per-page' => 10,
// 'data-total-items' => 117,
// ]
10. Rendering a Custom Twig Template
Instead of the built-in markup, hand off rendering to your own Twig template with
renderTemplate(). The Pagination instance is passed into the template as
pagination, giving you direct access to pagination.pageRange,
pagination.previousPage, pagination.nextPage, and pagination.url(n) —
set setMaxLinks() beforehand if you want pagination.pageRange to reflect a window
rather than every page:
$pagination->setMaxLinks(5);
echo $pagination->renderTemplate('@app/components/pagination.twig');
<nav>
{{# pagination.previousPage %} <a href="{{ pagination.url(pagination.previousPage) }}">←</a> {{% endif %}
{{% for page in pagination.pageRange %}
<a href="{{ pagination.url(page) }}">{{ page }}</a>
{{% endfor %}
{{# pagination.nextPage %} <a href="{{ pagination.url(pagination.nextPage) }}">→</a> {{% endif %}
</nav>
renderTemplate() resolves the framework's Twig environment via
Container::getInstance(), so it requires a bootstrapped container — see the
Service Container documentation. Plain render() has
no such requirement and works anywhere, including isolated unit tests.
11. New CSS Class Keys
In addition to the keys from section 4, setCssClasses() now also accepts:
ellipsis– the "…" shown between a First/Last link and the windowed page rangejumpForm– the jump-to-page<form>jumpLabel– the "Jump to page:"<label>jumpMenu– the<select>itselfjumpButton– the "Go" fallback<button>
12. AJAX Pagination (Zero Full-Page Reloads)
Combine toAjaxPayload() with Request::wantsJson() to get near-instant page transitions on a rapidly changing dataset, without giving up a fast, fully-rendered initial page load or risking stale cached HTML — the same controller and the same route serve both the normal page load and the AJAX request, so there's no separate API endpoint or duplicated list-rendering logic to maintain.
A. Include the script once
{% include "@core/partials/pagination-ajax.twig" %}
B. Wire up the controller
public function index(Request $request): Response
{
$pagination = new Pagination($totalItems, 20, (int) $request->get('page', 1));
$products = $this->productModel->paginated($pagination->getLimit(), $pagination->getOffset());
$listHtml = $this->twig->render('@app/partials/product-list.twig', ['products' => $products]);
if ($request->wantsJson()) {
return $this->json($pagination->toAjaxPayload($listHtml));
}
return $this->view('@app/marketplace/index.twig', [
'products' => $products,
'listHtml' => $listHtml,
'pagination' => $pagination,
]);
}
C. Wrap the markup
data-list-target, data-pagination-target, and data-jump-target tell
the script which elements to swap — the last is optional, only needed when
setShowJumpMenu(true) is in use:
<div data-ajax-pagination
data-list-target="#product-list"
data-pagination-target="#product-pagination"
data-jump-target="#product-jump">
<div id="product-list">
{{ listHtml|raw }}
</div>
<div id="product-pagination">
{{ pagination.render({showJumpMenu: false})|raw }}
</div>
<div id="product-jump">
{{ pagination.renderJumpMenu()|raw }}
</div>
</div>
render()'s output is a real,
fully-working href, and the jump-menu is a real <form> — with JavaScript
disabled (or before the script has finished loading) the page just navigates normally. The script only
intercepts what it can enhance.