PHPでページネーションを実装する方法|LIMIT・OFFSETでページ送りを作る


PHPのページネーションとは、件数の多い一覧を1ページあたりの表示数で区切り、「前へ・次へ・ページ番号」で行き来できるようにする仕組みのことです。実装の核は、MySQLのLIMIT(1ページの件数)とOFFSET(読み飛ばす件数)で表示範囲を絞り、総件数から総ページ数を計算してリンクを生成することです。

商品一覧や検索結果が数百件あると、1ページに全部出すのは現実的ではありません。表示が重くなり、ユーザーも目的の情報にたどり着けません。そこで一覧を「20件ずつ」のようにページで区切るのがページネーション(ページ送り)です。一覧機能を作ったら、ほぼ必ず必要になる定番の実装です。

この記事では、PHP+MySQLでページネーションを一から実装する手順を、動くコードと「なぜそう書くか」をセットで解説します。LIMITOFFSETの使い方、総ページ数の計算、ページリンクの生成、そして検索条件を保持したままページ送りする方法まで、実務でそのまま使える形でまとめます。データ取得の基礎はPHPでMySQLを操作する方法(CRUD)を前提にします。


PHPのページネーションとは(全体像)

ページネーションは、4つの値さえ押さえれば理解できます。1ページの表示件数、現在のページ番号、読み飛ばす件数(OFFSET)、そして全体の総件数です。これらの関係を整理しておきます。

意味求め方
perPage1ページの表示件数自分で決める(例: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 = 01〜20件目
2ページ目(2-1)×20 = 2021〜40件目
3ページ目(3-1)×20 = 4041〜60件目

LIMITとOFFSETでページを区切る

OFFSETが決まったら、LIMITOFFSETを付けてそのページ分だけ取得します。ここで重要な注意点があります。LIMITOFFSETは整数として渡す必要があるため、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'のようになって構文エラーになることがあります。ページネーションで最初につまずく定番ポイントなので、LIMITOFFSETbindValue()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の性能)

LIMITOFFSET方式は分かりやすく、小〜中規模では十分実用的です。ただし1つ弱点があります。OFFSETが大きくなると遅くなるという点です。

たとえばOFFSET 100000は、「10万件を読み飛ばして次の20件を返す」という動作で、MySQLは飛ばす分の行も内部的に走査します。後ろのページほど重くなるわけです。一覧が数万件を超え、深いページまでたどられる場合は、次のキーセットページネーション(後述)を検討します。数百〜数千件規模であれば、LIMITOFFSETで問題ありません。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. 件数の区切り:1ページに指定件数(例:20件)だけ表示されるか
  2. ページ移動:2ページ目・最終ページで、正しい範囲のデータが出るか
  3. 端の制御:1ページ目で「前へ」、最終ページで「次へ」が出ていないか
  4. 不正な値:?page=0?page=999でもエラーにならず安全に表示されるか
  5. 検索との併用:検索した状態で2ページ目に進んでも、検索条件が保持されているか
  6. 総ページ数:総件数と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(無限スクロール等)と相性が良い方式です。まずはLIMITOFFSETで作り、規模と要件に応じてこちらへ移行するのが現実的です。

ページネーションは、一覧・検索・CRUDを束ねる仕上げの機能です。検索機能CRUDに本記事を組み合わせれば、実用的な管理画面の骨格がひと通りそろいます。


よくある質問

Q. LIMITとOFFSETを配列で渡すとエラーになります。

LIMITOFFSETは整数で渡す必要があるためです。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のページネーションは、LIMITOFFSETで表示範囲を区切り、COUNTceilで総ページ数を求め、リンクを生成する——この流れが基本です。実装時は、LIMIT/OFFSETをPARAM_INTでバインドし、ORDER BYで並びを固定し、検索条件をhttp_build_query()で保持する。この3点を押さえれば、破綻しないページ送りが作れます。

まずは本記事のコードをローカルで動かし、検証チェックリスト——特に検索との併用とORDER BY——を確認してみてください。規模が大きくなったら、キーセットページネーションへの移行を検討します。

一覧・検索・ページネーションを含むWebアプリの実装や改修を相談したい場合は、Web制作・開発を手がけるRINIAにお問い合わせください。