PHP Pagination with Previous and Next Links
Previous and next links look like a small PHP problem until the database grows. Keep the database doing the slicing, validate the page number, bind the values through PDO, and treat deep pagination as a performance question.
Passing a raw start offset around feels more direct than calculating one from a page number. It works until someone sends a negative value, skips the first page, or pastes the variable into SQL without thinking. The fix is simple, and it has to be deliberate.
Guide Path
Read PHP control flow first, because pagination is mostly input validation plus a database query. After this, the same pattern shows up in the PHP search tutorial, where the result set also needs a stable order and a safe page size.
How Do You Validate a PHP Pagination Page Number?
Validate the requested page as a positive integer before calculating its SQL offset. The browser URL should expose a page number because that is what people understand:
/articles?page=3 Read it as an integer and clamp it to page one or higher:
<?php
$page = filter_input(
INPUT_GET,
"page",
FILTER_VALIDATE_INT,
[
"options" => [
"default" => 1,
"min_range" => 1,
"max_range" => 100000,
],
],
);
$page = max(1, (int) $page);
$perPage = 10;
$offset = ($page - 1) * $perPage; The upper bound prevents extreme offsets and integer overflow. Choose a limit appropriate to your app; malformed or out-of-range input falls back to page one in this example.
How Do You Fetch One Page with PDO?
Fetch one page with PDO by binding integer limits and offsets to a SQL query with a stable sort order. Do not fetch the full result set and slice it in PHP:
SELECT id, title, slug, published_at
FROM articles
WHERE published_at <= NOW()
ORDER BY published_at DESC, id DESC
LIMIT :limit OFFSET :offset; The example uses MySQL-compatible SQL and assumes $pdo is an existing PDO connection. Bind the integer values before executing:
<?php
$statement = $pdo->prepare(
"SELECT id, title, slug, published_at
FROM articles
WHERE published_at <= NOW()
ORDER BY published_at DESC, id DESC
LIMIT :limit OFFSET :offset",
);
$statement->bindValue("limit", $perPage, PDO::PARAM_INT);
$statement->bindValue("offset", $offset, PDO::PARAM_INT);
$statement->execute();
$articles = $statement->fetchAll(); A stable ORDER BY clause matters for predictable pagination. If rows share the same timestamp and the query has no secondary sort, page boundaries can shift while readers move through the list.
If this query belongs to a search screen, read building a search application with PHP and SQL next. Search pagination has the same mechanics, plus the extra cost of matching and ranking.
How Do You Check Whether Another Page Exists?
Check for another page by fetching one extra row, or count the matching rows when the interface needs a total. The first option is a separate count query:
SELECT COUNT(*) AS total
FROM articles
WHERE published_at <= NOW(); That gives you page counts and "Page 3 of 12" style UI, but the count can be expensive on larger filtered result sets.
The second option fetches one extra row from the result:
<?php
$limit = $perPage + 1;
$statement->bindValue("limit", $limit, PDO::PARAM_INT);
$statement->bindValue("offset", $offset, PDO::PARAM_INT);
$statement->execute();
$rows = $statement->fetchAll();
$hasNext = count($rows) > $perPage;
$articles = array_slice($rows, 0, $perPage); That is usually enough for previous and next links. You know whether another page exists without promising a total count that may be slow or stale.
How Do You Build Previous and Next Links?
Keep existing filters when changing the page number. http_build_query() will encode the query string for you:
<?php
function pageUrl(int $page): string
{
$query = $_GET;
$query["page"] = $page;
return "/articles?" . http_build_query($query);
} Render previous and next links only when they make sense:
<?php if ($page > 1): ?>
<a href="<?= htmlspecialchars(pageUrl($page - 1), ENT_QUOTES) ?>">Previous</a>
<?php endif; ?>
<?php if ($hasNext): ?>
<a href="<?= htmlspecialchars(pageUrl($page + 1), ENT_QUOTES) ?>">Next</a>
<?php endif; ?> Query-string encoding and HTML escaping do different jobs. Keep both, and accept only the filters your application supports.
When Should You Use Keyset Pagination?
Use keyset pagination for deep chronological lists, activity feeds, and large result sets where readers move forward from the last item. Offset pagination is fine for small and medium lists. The database still has to walk past skipped rows, though, so page 4,000 can be much more expensive than page 4.
When readers need to move through deep chronological lists, use keyset pagination instead. The URL carries the last seen position rather than a page number:
SELECT id, title, slug, published_at
FROM articles
WHERE published_at <= NOW()
AND (published_at < :cursor_published_at
OR (published_at = :cursor_same_time AND id < :cursor_id))
ORDER BY published_at DESC, id DESC
LIMIT :limit; Bind both timestamp placeholders to the last displayed row’s timestamp and :cursor_id to its ID. With a suitable index, this query can start after that row instead of scanning every earlier page. It is less friendly for jumping to page 27, but it is better for endless lists, activity feeds, and large result sets.
The SQL indexes and query optimization guide covers why this works. Pagination is still a query-plan problem, even when the visible feature is just two links.
Keep Pagination in the Query
Pagination belongs close to the query because the database owns the order, filters, and row limits. PHP should validate the request, bind the values, render the links, and escape the output.
That separation is the same one used in PHP and MySQL with PDO and building a small PHP database app. The page link is a tiny UI feature, but the correctness lives in the database boundary underneath it.
Frequently Asked Questions
Does LIMIT and OFFSET work the same in every database?
LIMIT and OFFSET are available in MySQL, PostgreSQL and SQLite, but not every database uses the same pagination syntax. SQL Server uses OFFSET and FETCH. Check syntax and query performance on the database you deploy.
Should page numbers in a URL start at 0 or 1?
Public pagination URLs usually start at page 1. Readers count from one, and a URL saying page=1 for the first page matches what a person expects. Convert to the zero-based offset in the query rather than exposing it in the address.
What happens when someone requests a page past the last one?
A pagination query with an offset beyond the last row returns an empty result rather than an error. Treat an empty result at a requested page as a normal case to handle, not a failure.
Is deep pagination good for search engines?
Paginated pages can be crawled when each page has its own URL and ordinary links connect the sequence. Use a self-referencing canonical for each page rather than pointing every page to page one. A sitemap can help discovery but does not replace crawlable navigation.
Sources
-
[1]
filter_input(php.net)
-
[2]
PDO::prepare(php.net)
-
[3]
PDOStatement::fetchAll(php.net)
-
[4]
http_build_query(php.net)
Read Next
Use PDO prepared statements to connect PHP to MySQL safely and keep SQL input handling clear.
Extend the PHP web-app path with prepared search queries, pagination, indexed SQL, and a clean upgrade path to full-text search.
Learn what SQL indexes do, what they cost, and how to use EXPLAIN without guessing.