Pagination with PHP

La Lakshmi Priya Updated 04 Nov 2026
6 min read ·Lesson 225 of 235

Pagination with PHP What Is Pagination? In simple terms: When a list has hundreds or thousands of entries — products, orders, blog posts — showing them all on one page is slow and overwhelming. Pagination splits the list into pages of a fixed size (for example, 20 items) and lets users move between them with "Previous", "Next", and page-number links.

Technically: Offset pagination combines two queries: a COUNT(*) to know how many items exist (and therefore how many pages), and a SELECT ... ORDER BY ... LIMIT perPage OFFSET (page − 1) × perPage to fetch the current page. The page number comes from the URL (?page=2), so it must be validated. For very large tables, keyset pagination is a faster alternative.

The examples use the five products from the previous lessons, sorted by name: Backpack, Desk Lamp, Gel Pen, Notebook A5, Wireless Mouse.

The Pagination Math <?php function paginate(int $totalItems, int $perPage, int $requestedPage): array { $totalPages = max(1, (int) ceil($totalItems / $perPage)); $page = min(max(1, $requestedPage), $totalPages); $offset = ($page - 1) * $perPage;

return [ 'page' => $page, 'total_pages' => $totalPages, 'per_page' => $perPage, 'offset' => $offset, 'from' => $totalItems === 0 ? 0 : $offset + 1, 'to' => min($offset + $perPage, $totalItems), 'total_items' => $totalItems, ]; }

foreach ([[5, 2, 2], [5, 2, 99], [5, 2, -3], [0, 2, 1]] as [$total, $perPage, $requested]) { $p = paginate($total, $perPage, $requested); printf( "requested %3d → page %d of %d, offset %d, showing %d–%d of %d\n", $requested, $p['page'], $p['total_pages'], $p['offset'], $p['from'], $p['to'], $p['total_items'] ); } Output requested 2 → page 2 of 3, offset 2, showing 3–4 of 5 requested 99 → page 3 of 3, offset 4, showing 5–5 of 5 requested -3 → page 1 of 3, offset 0, showing 1–2 of 5 requested 1 → page 1 of 1, offset 0, showing 0–0 of 0 How It Works Total pages = total items ÷ items per page, rounded up (5 items ÷ 2 per page = 3 pages). At least one page exists even when the list is empty. The requested page is clamped between 1 and the last page, so ?page=99 or ?page=-3 cannot cause errors or empty pages. Offset = (page − 1) × per page: page 1 starts at 0, page 2 at 2, page 3 at 4. from and to produce the familiar "Showing 3–4 of 5" text. Keeping this logic in a pure function makes it easy to test without a database.

Fetching a Page From the Database <?php require DIR . '/src/db.php';

$db = db(); $perPage = 2;

$requestedPage = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['default' => 1]]);

$totalItems = (int) $db->query('SELECT COUNT(*) FROM products')->fetch_column(); $p = paginate($totalItems, $perPage, $requestedPage);

$stmt = $db->prepare('SELECT name, price FROM products ORDER BY name, id LIMIT ? OFFSET ?'); $stmt->bind_param('ii', $p['per_page'], $p['offset']); $stmt->execute(); $products = $stmt->get_result()->fetch_all(MYSQLI_ASSOC);

echo "Showing {$p['from']}–{$p['to']} of {$p['total_items']} products (page {$p['page']} of {$p['total_pages']})\n";

foreach ($products as $product) { echo "- {$product['name']} (₹{$product['price']})\n"; } Output (for ?page=2) Showing 3–4 of 5 products (page 2 of 3)

  • Gel Pen (₹20.00)

  • Notebook A5 (₹60.00) Step-by-Step Explanation The page number is validated with FILTER_VALIDATE_INT; invalid input such as ?page=abc falls back to page 1. COUNT(*) determines the total number of items, which paginate() uses to calculate the page and offset. ORDER BY name, id sorts the list. The unique id as tie-breaker guarantees a stable order: without it, products with equal names could swap places between requests, appearing on two pages or on none. LIMIT ? OFFSET ? is bound with bind_param('ii', ...), because LIMIT requires integers. Any filter applied to the list (category, search) must be used in both queries — the count and the page query. Rendering Page Links <?php function paginationLinks(array $p, array $query = []): string { $link = fn(int $page, string $label): string => sprintf( '%s', htmlspecialchars(http_build_query([...$query, 'page' => $page])), $label );

    $parts = [];

    if ($p['page'] > 1) { $parts[] = $link($p['page'] - 1, '« Prev'); }

    for ($i = 1; $i <= $p['total_pages']; $i++) { $parts[] = $i === $p['page'] ? "$i" : $link($i, (string) $i); }

    if ($p['page'] < $p['total_pages']) { $parts[] = $link($p['page'] + 1, 'Next »'); }

    return "

    \n " . implode("\n ", $parts) . "\n\n"; }

echo paginationLinks(paginate(5, 2, 2), ['sort' => 'name']); Output (HTML)

« Prev 1 2 3 Next » How It Works http_build_query() builds each link's query string, keeping other parameters such as the sort order or a search term, and replacing only page. The query string is escaped with htmlspecialchars() (& becomes &), as required inside HTML attributes. The current page is shown as plain text, not as a link; "Prev" and "Next" appear only when there is a previous or next page. For lists with many pages, show only a window of page numbers around the current page (for example, 1 … 7 8 [9] 10 11 … 50) instead of every number.

Keyset Pagination for Large Tables With OFFSET, MySQL must still read and discard all skipped rows: page 5,000 of a large table means skipping 100,000 rows. Keyset pagination (also called the "seek method") continues after the last item of the previous page instead:

<?php require DIR . '/src/db.php';

function productsAfter(mysqli $db, int $afterId, int $limit): array { $stmt = $db->prepare('SELECT id, name FROM products WHERE id > ? ORDER BY id LIMIT ?'); $stmt->bind_param('ii', $afterId, $limit); $stmt->execute();

return $stmt->get_result()->fetch_all(MYSQLI_ASSOC); }

$page = productsAfter(db(), 2, 2); // the previous page ended with ID 2

foreach ($page as $product) { echo "#{$product['id']} {$product['name']}\n"; }

echo "Next cursor: " . end($page)['id'] . "\n"; Output #3 Desk Lamp #4 Backpack Next cursor: 4 The "Next" link carries the last ID (?after=4). Using the primary key index, MySQL jumps directly to the right position — page 5,000 is as fast as page 1.

Offset vs Keyset Pagination Offset (LIMIT … OFFSET …) Keyset (WHERE id > ? … LIMIT …) Jump to any page number Yes No (only next/previous) Speed on deep pages Slows down as the offset grows Constant Stable when rows are inserted or deleted Items can shift between pages Yes Typical use Admin tables, search results with page numbers Infinite scroll, feeds, APIs, exports Keyset pagination with a different sort column (such as created_at) needs a unique tie-breaker: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC.

Common Mistakes Paginating Without a Stable Order Effect: Items appear twice or never.

Fix: ORDER BY with a unique tie-breaker such as id.

Using the Page Number Without Validation Effect: Errors with negative offsets or empty pages for huge numbers.

Fix: Validate as an integer and clamp to the valid range.

Forgetting Filters in the Count Query Effect: Wrong page counts ("page 3 of 10" when only 2 pages have results).

Fix: Apply the same WHERE conditions to both queries.

Losing Other Query Parameters in Links Effect: Clicking "Next" resets the search or sort order.

Fix: Build links with http_build_query() from the current parameters.

Deep Offset Pagination on Huge Tables Effect: Slow queries.

Fix: Use keyset pagination.

Best Practices Validate and clamp the page number; cap the page size. Sort with a unique tie-breaker for stable pages. Use the same filters for the count and the page query. Preserve other query parameters and escape URLs in links. Use keyset pagination for large datasets, feeds, and APIs. Official reference: https://www.php.net/manual/en/mysqli.quickstart.prepared-statements.php

0 Comments

Reviewed before they appear

No comments yet.

PHP
PHP Home PHP Switch PHP Match Alternative Syntax PHP Loops While Loop Do While Loop For Loop Foreach Loop Nested Loops Break Statement PHP Intro PHP Install PHP Built-in Web Server PHP Syntax PHP Tags PHP and HTML PHP Comments PHP Multiline Comments PHP Variables Variables Scope PHP Casting Logical Operators Null Coalescing Operator Spaceship Operator Operator Precedence Continue Statement Modify Strings PHP If...Else...Elseif PHP If Modify Strings PHP If Operators PHP Functions Concatenate Strings PHP If...Else User-defined Functions Slicing Strings Function Arguments PHP Shorthand if Escape Characters Default Argument Values PHP Nested if Pass by Reference String Interpolation Named Arguments PHP File Upload Variadic Functions Return Values Search in Strings Multibyte Strings Type Declarations in Functions PHP Numbers PHP Math PHP Floats PHP Random Numbers PHP Constants PHP Properties PHP Methods PHP Constructor Constructor Property Promotion PHP Cookies PHP Destructor PHP Access Modifiers Readonly Properties PHP Sessions PHP Inheritance Method Overriding PHP Filters The final Keyword CSRF Protection PHP Filters Advanced Session Security Secure Cookies PHP Magic Constants File Upload Security PHP Callback Functions PHP Operators Filesystem Security PHP JSON Arithmetic Operators Error Reporting in Production Hiding PHP Assignment Operators PHP Configuration (php.ini) Comparison Operators PHP Command Line (CLI) PHP Exceptions Increment / Decrement Operators Migrating to PHP 8 PHP Form Validation PHP Form Required PHP Form URL/E-mail Error Handling Form Input Sanitizing Escaping Output (htmlspecialchars) Custom Error Handler Checkbox, Radio and Select Inputs Sticky Forms PHP Form Complete PHP Date and Time Date Formatting Error Logging HTTP Headers and Redirects Output Buffering Sending Email PHP cURL Consuming REST APIs PHP Generators AJAX PHP AJAX Database AJAX XML AJAX Live Search AJAX Poll AJAX with JSON Security Introduction Password Hashing SQL Injection Prevention XSS Prevention Garbage Collection Project: Simple Blog PHP What is OOP PHP Classes/Objects The $this Keyword Access Array Items Update Array Items Add Array Items Remove Array Items Sorting Arrays Multidimensional Arrays Array Destructuring Array Spread Operator Search in Arrays Merge Arrays PHP Abstract Classes PHP Interfaces Polymorphism PHP Traits PHP Static Methods PHP Static Properties Late Static Binding Magic Methods Object Cloning MySQL Database MySQL Connect MySQL Create DB MySQL Create Table MySQL Insert Data MySQL Get Last ID MySQL Insert Multiple MySQL Prepared MySQL Select Data MySQL Where Debugging PHP Project: Image Upload Gallery $_SESSION $_ENV PHP RegEx RegEx Patterns Heredoc and Nowdoc RegEx Modifiers preg_match preg_replace preg_split PHP RegEx Functions Nullable Types Mixed and Never Types Nullsafe Operator PHP Attributes PHP Fibers Readonly Classes Property Hooks (PHP 8.4) Asymmetric Visibility (PHP 8.4) Reflection API SPL Data Structures Composer PHP Form Handling PDO Transactions PDO Error Handling PHP XML Parsers PHP SimpleXML Parser PHP SimpleXML - Get PHP XML Expat Parser PHP DOM Parser PHP XMLReader PHP XMLWriter AJAX Intro PHP Class Constants Encryption with Sodium Recursive Functions Variable Functions Anonymous Functions Closures Arrow Functions First-class Callable Syntax PHP Arrays Indexed Arrays Associative Arrays Create Arrays Variables Scope Static Variables Variable Variables PHP Echo / Print PHP printf / sprintf PHP Data Types PHP Booleans PHP NULL PHP Strings String Functions array_map / array_filter / array_reduce Array Functions PHP Superglobals $GLOBALS What Is `$_SERVER` ? $_REQUEST $_POST $_GET $_FILES $_COOKIE PHP Type Juggling MySQL Order By MySQL Delete Data MySQL Update Data MySQL Limit Data MySQL Joins with PHP Pagination with PHP PDO Introduction PDO Connect PDO Prepared Statements PDO Fetch Modes PSR-4 Autoloading Installing Extensions (PECL / PIE) OPcache & Performance PHP-FPM Project: Contact Form with Email Project: Login & Registration System
Ask about this post
AI Ask about this post

Ask questions about Pagination with PHP and get answers drawn from it.

Signed-in readers only.