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 link
  • disabled – class on the disabled Previous/Next text
  • active – 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>
Note: 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 range
  • jumpForm – the jump-to-page <form>
  • jumpLabel – the "Jump to page:" <label>
  • jumpMenu – the <select> itself
  • jumpButton – 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>
Progressive by design: every link in 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.