PHPのページネーションとは、件数の多い一覧を1ページあたりの表示数で区切り、「前へ・次へ・ページ番号」で行き来できるようにする仕組みのことです。実装の核は、MySQLのLIMIT(1ページの件数)とOFFSET(読み飛ばす件数)で表示範囲を絞り、総件数から総ページ数を計算してリンクを生成することです。
商品一覧や検索結果が数百件あると、1ページに全部出すのは現実的ではありません。表示が重くなり、ユーザーも目的の情報にたどり着けません。そこで一覧を「20件ずつ」のようにページで区切るのがページネーション(ページ送り)です。一覧機能を作ったら、ほぼ必ず必要になる定番の実装です。
この記事では、PHP+MySQLでページネーションを一から実装する手順を、動くコードと「なぜそう書くか」をセットで解説します。LIMITとOFFSETの使い方、総ページ数の計算、ページリンクの生成、そして検索条件を保持したままページ送りする方法まで、実務でそのまま使える形でまとめます。データ取得の基礎はPHPでMySQLを操作する方法(CRUD)を前提にします。
PHPのページネーションとは(全体像)
ページネーションは、4つの値さえ押さえれば理解できます。1ページの表示件数、現在のページ番号、読み飛ばす件数(OFFSET)、そして全体の総件数です。これらの関係を整理しておきます。
| 値 | 意味 | 求め方 |
|---|---|---|
| perPage | 1ページの表示件数 | 自分で決める(例:20) |
| page | 現在のページ番号 | URLの?page=から受け取る |
| offset | 読み飛ばす件数 | (page - 1) × perPage |
| total | 総件数 | SELECT COUNT(*)で取得 |
| totalPages | 総ページ数 | ceil(total ÷ perPage) |
たとえば1ページ20件で2ページ目を表示するなら、offset = (2 - 1) × 20 = 20。つまり「最初の20件を飛ばして、次の20件を取る」という意味になります。この計算がページネーションの心臓部です。
事前準備:表示件数とページ番号、総件数の取得
②③と同じitemsテーブルを使います。まず、URLから現在のページ番号を受け取り、表示件数とOFFSETを決め、総件数を取得します。
<?php
require 'db.php';
$perPage = 20;
$page = max(1, (int)($_GET['page'] ?? 1)); // 1未満は1に丸める
$offset = ($page - 1) * $perPage;
// 総件数を取得(COUNT(*)はfetchColumnで1値だけ受け取れる)
$total = (int)$pdo->query('SELECT COUNT(*) FROM items')->fetchColumn();
$totalPages = (int)ceil($total / $perPage); // 端数は切り上げ ページ番号は(int)でキャストし、max(1, ...)で最小1に丸めます。これで?page=abcや?page=-5のような不正な値が来ても安全です。総ページ数はceil()で切り上げます(21件を20件区切りにすると2ページ必要なため)。
ページ番号とOFFSET、取得範囲の対応を具体的な数字で見ると、計算の意味がつかめます(1ページ20件の場合)。
| ページ | offset の計算 | 取得する範囲 |
|---|---|---|
| 1ページ目 | (1-1)×20 = 0 | 1〜20件目 |
| 2ページ目 | (2-1)×20 = 20 | 21〜40件目 |
| 3ページ目 | (3-1)×20 = 40 | 41〜60件目 |
LIMITとOFFSETでページを区切る
OFFSETが決まったら、LIMITとOFFSETを付けてそのページ分だけ取得します。ここで重要な注意点があります。LIMITとOFFSETは整数として渡す必要があるため、execute()の配列渡しではなくbindValue()でPDO::PARAM_INTを明示します。
<?php
$sql = 'SELECT * FROM items ORDER BY id DESC
LIMIT :limit OFFSET :offset';
$stmt = $pdo->prepare($sql);
// LIMIT・OFFSET は整数として明示的にバインドする
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$items = $stmt->fetchAll(); execute([':limit' => $perPage])のように配列で渡すと、値が文字列としてバインドされ、LIMIT '20'のようになって構文エラーになることがあります。ページネーションで最初につまずく定番ポイントなので、LIMIT・OFFSETはbindValue()+PARAM_INTと覚えておきましょう。バインドの挙動はPHP公式マニュアル(bindValue)でも確認できます。また、ORDER BYを必ず付けることも重要です。並び順が固定されていないと、ページをまたいだときに同じ行が重複したり抜けたりするためです。
ページ番号リンクを生成する(前へ・次へ・番号)
取得した$totalPagesを使って、ページ移動のリンクを出力します。基本は「前へ」「ページ番号」「次へ」の3パーツです。現在のページはリンクにせず、強調表示にします。
<?php
echo '<nav class="pagination">';
// 「前へ」:1ページ目では出さない
if ($page > 1) {
echo '<a href="?page=' . ($page - 1) . '">前へ</a>';
}
// ページ番号
for ($i = 1; $i <= $totalPages; $i++) {
if ($i === $page) {
echo '<span class="current">' . $i . '</span>'; // 現在地
} else {
echo '<a href="?page=' . $i . '">' . $i . '</a>';
}
}
// 「次へ」:最終ページでは出さない
if ($page < $totalPages) {
echo '<a href="?page=' . ($page + 1) . '">次へ</a>';
}
echo '</nav>'; 「前へ」は1ページ目で、「次へ」は最終ページで非表示にします。これを忘れると、存在しない0ページ目や、空のページに飛べてしまいます。ページ数が非常に多い場合は、すべての番号を出すのではなく「現在地の前後数ページだけ」を出す形に絞ると見やすくなります。次のように現在ページを基準に範囲を限定し、両端を「…」で省略します。
<?php
$range = 2; // 現在地の前後に表示するページ数
$start = max(1, $page - $range);
$end = min($totalPages, $page + $range);
// 先頭が範囲外なら「1 …」を補う
if ($start > 1) {
echo '<a href="?page=1">1</a> … ';
}
for ($i = $start; $i <= $end; $i++) {
echo ($i === $page)
? '<span class="current">' . $i . '</span>'
: '<a href="?page=' . $i . '">' . $i . '</a>';
}
// 末尾が範囲外なら「… 最終」を補う
if ($end < $totalPages) {
echo ' … <a href="?page=' . $totalPages . '">' . $totalPages . '</a>';
} $rangeを変えれば、現在地の前後に出すページ数を調整できます。これで100ページあっても、リンクが横にあふれず常に見やすい形に収まります。
検索・絞り込み条件を保持したままページ送りする
実務でつまずくのがここです。検索結果をページ送りすると、2ページ目で検索キーワードが消えて全件に戻ってしまう——よくある不具合です。原因は、ページリンクが?page=2だけで、検索条件(?keyword=...)を引き継いでいないこと。
解決策は、現在のGETパラメータを保持したままpageだけ差し替えてURLを組み立てることです。http_build_query()を使うと安全に組めます。検索機能の実装と組み合わせる際の要となる部分です。
<?php
// 現在のGETパラメータを保持し、pageだけ差し替えてURLを作る
function pageUrl(int $page): string {
$query = $_GET; // 例: ['keyword' => 'コーヒー', 'page' => '2']
$query['page'] = $page; // pageだけ上書き
return '?' . http_build_query($query);
}
// リンク出力時はエスケープして使う
$url = htmlspecialchars(pageUrl($page + 1), ENT_QUOTES, 'UTF-8');
echo '<a href="' . $url . '">次へ</a>'; 総件数の取得も、検索時は同じWHERE条件を付ける必要があります。一覧のCOUNT(*)と表示用のSELECTで条件を揃えないと、「総ページ数は5なのに3ページ目以降が空」といったズレが起きます。検索条件はCOUNT側にも必ず反映させましょう。
LIMIT/OFFSETの注意点(大きなOFFSETの性能)
LIMIT/OFFSET方式は分かりやすく、小〜中規模では十分実用的です。ただし1つ弱点があります。OFFSETが大きくなると遅くなるという点です。
たとえばOFFSET 100000は、「10万件を読み飛ばして次の20件を返す」という動作で、MySQLは飛ばす分の行も内部的に走査します。後ろのページほど重くなるわけです。一覧が数万件を超え、深いページまでたどられる場合は、次のキーセットページネーション(後述)を検討します。数百〜数千件規模であれば、LIMIT/OFFSETで問題ありません。LIMIT句の正式な仕様はMySQL公式マニュアル(SELECT構文)も参照してください。
ページネーションでやりがちな失敗
実装時に起きやすい問題を、原因と対処でまとめます。多くはOFFSET計算と条件の不一致に集約されます。
| 症状 | 主な原因 | 対処 |
|---|---|---|
| LIMIT句で構文エラー | 配列渡しで文字列バインド | bindValue()+PDO::PARAM_INTを使う |
| ページ移動で行が重複/抜ける | ORDER BYがない | 一意で安定した順序(例:id)で並べる |
| 2ページ目で検索条件が消える | リンクがpageしか持たない | http_build_query()で条件を保持 |
| 総ページ数と中身がずれる | COUNTとSELECTの条件不一致 | 両方に同じWHEREを適用 |
特にORDER BYなしの落とし穴は気づきにくいものです。1ページ目では正しく見えても、MySQLが返す順序は保証されないため、ページをまたぐと並びが変わり、同じ行が二度出たり消えたりします。ページネーションでは並び順の固定が必須です。
動作確認:実際に動かして検証する
実装したら、実際に動かしてページ送りが破綻しないかを確認します。サンプルデータを多めに入れて、次の観点をチェックしてください。
- 件数の区切り:1ページに指定件数(例:20件)だけ表示されるか
- ページ移動:2ページ目・最終ページで、正しい範囲のデータが出るか
- 端の制御:1ページ目で「前へ」、最終ページで「次へ」が出ていないか
- 不正な値:
?page=0や?page=999でもエラーにならず安全に表示されるか - 検索との併用:検索した状態で2ページ目に進んでも、検索条件が保持されているか
- 総ページ数:総件数と
ceilの計算が合っているか(端数のページが正しく出るか)
5番目の「検索+ページ送り」は、単体では気づきにくい組み合わせの不具合が出やすい箇所です。検索機能と必ずセットで検証してください。
次の一手:キーセットページネーション
OFFSET方式の性能問題を回避する方法が、キーセットページネーション(シーク法)です。「何件目から」ではなく「前ページの最後のIDより小さいものから」という条件で次のページを取ります。
-- OFFSET方式(後ろのページほど遅い)
SELECT * FROM items ORDER BY id DESC LIMIT 20 OFFSET 100000;
-- キーセット方式(前ページ末尾のidを基準にする・速い)
SELECT * FROM items WHERE id < :last_id ORDER BY id DESC LIMIT 20; キーセット方式は、インデックスの効いた列(主キーなど)を基準にするため、ページが深くなっても速度が落ちにくいのが利点です。一方で「3ページ目へジャンプ」のような番号での飛び先指定は苦手で、「次へ」中心のUI(無限スクロール等)と相性が良い方式です。まずはLIMIT/OFFSETで作り、規模と要件に応じてこちらへ移行するのが現実的です。
ページネーションは、一覧・検索・CRUDを束ねる仕上げの機能です。検索機能とCRUDに本記事を組み合わせれば、実用的な管理画面の骨格がひと通りそろいます。
よくある質問
Q. LIMITとOFFSETを配列で渡すとエラーになります。
LIMITとOFFSETは整数で渡す必要があるためです。execute()の配列渡しでは値が文字列としてバインドされ、構文エラーになることがあります。bindValue(':limit', $perPage, PDO::PARAM_INT)のように、PDO::PARAM_INTを明示してバインドしてください。
Q. ORDER BYは必須ですか?
実質的に必須です。ORDER BYがないと、MySQLが返す行の順序は保証されません。その結果、ページをまたいだときに同じ行が重複したり抜けたりします。idなど一意で安定した列で並べてください。
Q. 検索結果をページ送りすると条件が消えてしまいます。
ページリンクが?page=2しか持っておらず、検索条件(?keyword=...)を引き継いでいないことが原因です。http_build_query()で現在のGETパラメータを保持し、pageだけ差し替えてURLを生成してください。総件数のCOUNTにも同じ検索条件を適用します。
Q. ページ数が多すぎてリンクが横に並びきりません。
全ページ番号を出すのではなく、「現在地の前後2〜3ページ+最初と最後」だけを表示する形にすると見やすくなります。間は「…」で省略します。forループの範囲を現在ページ基準に絞ることで実装できます。
Q. OFFSETが大きいページが遅いです。改善できますか?
OFFSET方式は飛ばす行も走査するため、深いページほど遅くなります。改善するには、前ページ末尾のidを基準にするキーセットページネーション(シーク法)が有効です。番号ジャンプより「次へ」中心のUIに向いた方式です。
まとめ
PHPのページネーションは、LIMITとOFFSETで表示範囲を区切り、COUNTとceilで総ページ数を求め、リンクを生成する——この流れが基本です。実装時は、LIMIT/OFFSETをPARAM_INTでバインドし、ORDER BYで並びを固定し、検索条件をhttp_build_query()で保持する。この3点を押さえれば、破綻しないページ送りが作れます。
まずは本記事のコードをローカルで動かし、検証チェックリスト——特に検索との併用とORDER BY——を確認してみてください。規模が大きくなったら、キーセットページネーションへの移行を検討します。
一覧・検索・ページネーションを含むWebアプリの実装や改修を相談したい場合は、Web制作・開発を手がけるRINIAにお問い合わせください。
