PHPでページネーションを作る方法について

採用はこちら

PHPでページネーションを作るには、一覧データを複数ページに分けて表示する仕組みを理解する必要があります。

ページネーションとは、記事一覧、商品一覧、検索結果、管理画面の一覧などで使われる「ページ送り」のことです。

例えば、記事が全部で100件あり、1ページに10件ずつ表示する場合、以下のように分けて表示します。

ページ表示するデータ
1ページ目1〜10件目
2ページ目11〜20件目
3ページ目21〜30件目
10ページ目91〜100件目

すべてのデータを1ページに表示すると、ページの読み込みが重くなったり、ユーザーが目的の情報を探しにくくなったりします。

そのため、データ件数が多い一覧ページでは、ページネーションを実装して、1ページあたりの表示件数を調整するのが一般的です。

目次

PHPでページネーションを作る流れ

PHPでページネーションを実装する基本的な流れは、以下の通りです。

  1. 現在のページ番号を取得する
  2. 1ページあたりの表示件数を決める
  3. 全データ件数を取得する
  4. 総ページ数を計算する
  5. 表示開始位置を計算する
  6. 現在のページに必要なデータだけ取得する
  7. 一覧データをHTMLに表示する
  8. ページ番号リンクを出力する

特に重要なのが、現在のページ番号と表示開始位置の計算です。

PHPとMySQLでページネーションを作る場合は、主にSQLの LIMITOFFSET を使います。

LIMITとOFFSETの考え方

MySQLでは、LIMITOFFSET を使うことで、取得するデータの件数と開始位置を指定できます。

例えば、以下のSQLは10件のデータを取得します。

SELECT *
FROM posts
LIMIT 10;

さらに、OFFSET を指定すると、指定した件数分を読み飛ばしてからデータを取得できます。

SELECT *
FROM posts
LIMIT 10 OFFSET 20;

これは、先頭から20件を読み飛ばして、その後の10件を取得するという意味です。

つまり、1ページあたり10件表示する場合、各ページの OFFSET は以下のようになります。

現在のページOFFSET
1ページ目0
2ページ目10
3ページ目20
4ページ目30

OFFSET は、次の式で計算できます。

$offset = ($currentPage - 1) * $perPage;

例えば、3ページ目で1ページあたり10件表示する場合は、以下のようになります。

$offset = (3 - 1) * 10;
// 20

これにより、3ページ目では21件目から30件目までを表示できます。

ページネーションに必要な変数

PHPでページネーションを作る際は、主に以下の変数を使います。

変数内容
$currentPage現在のページ番号
$perPage1ページあたりの表示件数
$totalCount全データ件数
$totalPages総ページ数
$offsetデータの取得開始位置

この5つを正しく扱えば、基本的なページネーションは実装できます。

PHPとMySQLでページネーションを作る方法

ここからは、PHPとMySQLを使ってページネーションを実装する方法を解説します。

例として、posts テーブルから記事一覧を取得するケースを想定します。

テーブル構成の例

以下のような posts テーブルを使う想定です。

CREATE TABLE posts (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  body TEXT,
  created_at DATETIME
);

記事一覧ページでは、idtitlebodycreated_at を取得して表示します。

PDOでデータベースに接続する

まず、PDOを使ってMySQLに接続します。

<?php
$dsn = 'mysql:host=localhost;dbname=sample_db;charset=utf8mb4';
$user = 'root';
$password = '';

try {
    $pdo = new PDO($dsn, $user, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
} catch (PDOException $e) {
    exit('データベース接続エラー: ' . $e->getMessage());
}

PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION を指定しておくと、SQL実行時にエラーが発生した場合、例外として検知できます。

また、PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC を指定しておくと、取得結果を連想配列として扱えます。

実務では、データベースの接続情報を直接ファイルに書くのではなく、環境変数や設定ファイルで管理するのが一般的です。

現在のページ番号を取得する

ページネーションでは、現在何ページ目を表示しているのかを取得する必要があります。

例えば、以下のようなURLを想定します。

posts.php?page=2

この場合、page=2 の値を取得して、2ページ目のデータを表示します。

filter_inputでページ番号を取得する

ページ番号は、filter_input() を使って整数として取得すると安全です。

$currentPage = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);

if ($currentPage === false || $currentPage === null || $currentPage < 1) {
    $currentPage = 1;
}

page パラメータはユーザーが自由に変更できます。

例えば、以下のようなURLでアクセスされる可能性があります。

posts.php?page=abc
posts.php?page=-1
posts.php?page=999999

そのため、ページ番号は必ず整数として扱い、1未満の値にならないようにします。

page が指定されていない場合や、不正な値が指定された場合は、1ページ目として扱うのが基本です。

1ページあたりの表示件数を決める

次に、1ページに何件表示するかを決めます。

$perPage = 10;

この例では、1ページあたり10件の記事を表示します。

ブログ記事一覧であれば10件前後、管理画面の一覧であれば20件や50件など、用途に応じて調整します。

ただし、1ページあたりの表示件数を多くしすぎると、ページの読み込みが重くなる可能性があります。

特に画像付きの商品一覧や記事一覧では、表示件数を適切に調整することが重要です。

全データ件数を取得する

総ページ数を計算するには、データが全部で何件あるかを取得する必要があります。

全件数は、SQLの COUNT(*) を使って取得します。

$countSql = 'SELECT COUNT(*) FROM posts';
$countStmt = $pdo->query($countSql);
$totalCount = (int) $countStmt->fetchColumn();

fetchColumn() を使うと、COUNT結果の数値だけを取得できます。

例えば、記事が95件ある場合、$totalCount には 95 が入ります。

総ページ数を計算する

全データ件数がわかったら、総ページ数を計算します。

$totalPages = (int) ceil($totalCount / $perPage);

ceil() は、小数点以下を切り上げる関数です。

例えば、記事が95件あり、1ページに10件ずつ表示する場合は、以下のようになります。

95 / 10 = 9.5

この場合、10ページ目に残り5件を表示する必要があります。

そのため、総ページ数は10ページになります。

データが0件の場合

データが0件の場合、以下の計算結果は0になります。

$totalPages = (int) ceil(0 / 10);
// 0

この場合は、ページネーションを表示せず、「記事がありません」と表示すれば問題ありません。

後述する完成コードでは、$totalPages > 1 の場合のみページネーションを表示するようにしています。

存在しないページ番号への対応

例えば、記事が5ページ分しかないのに、以下のようなURLでアクセスされる場合があります。

posts.php?page=999

このような場合は、実装方針に応じて対応を分けます。

管理画面では最後のページに補正してもよい

管理画面やアプリ内の一覧ページでは、存在しないページ番号にアクセスされた場合、最後のページに補正する方法があります。

if ($totalPages > 0 && $currentPage > $totalPages) {
    $currentPage = $totalPages;
}

この方法なら、page=999 のようなURLでも、最後のページを表示できます。

公開ページでは404を返す方法もある

ブログやメディアサイトなど、外部からアクセスされる公開ページでは、存在しないページ番号に対して404を返す方が自然な場合があります。

if ($totalPages > 0 && $currentPage > $totalPages) {
    http_response_code(404);
    exit('ページが見つかりません。');
}

存在しないページを無理に表示すると、不要なURLが増えてしまいます。

そのため、公開ページでは「最後のページに補正する」のか「404を返す」のかを、サイトの方針に合わせて決めるとよいでしょう。

OFFSETを計算する

現在のページ番号と1ページあたりの表示件数から、OFFSET を計算します。

$offset = ($currentPage - 1) * $perPage;

例えば、1ページあたり10件表示する場合は以下のようになります。

現在のページ計算式OFFSET
1ページ目(1 - 1) * 100
2ページ目(2 - 1) * 1010
3ページ目(3 - 1) * 1020

ここで注意したいのは、1ページ目の OFFSET は0であるという点です。

以下のように書いてしまうと、1ページ目で最初の10件が飛ばされてしまいます。

$offset = $currentPage * $perPage;

正しくは、必ず以下の形です。

$offset = ($currentPage - 1) * $perPage;

現在のページに表示するデータを取得する

現在のページに表示するデータだけを取得するには、LIMITOFFSET を使います。

$sql = 'SELECT id, title, body, created_at
        FROM posts
        ORDER BY created_at DESC, id DESC
        LIMIT :limit OFFSET :offset';

$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

$posts = $stmt->fetchAll();

ここで重要なのは、ORDER BY を指定することです。

ページネーションでは、並び順が安定していないと、ページをまたいだときに同じ記事が重複して表示されたり、一部の記事が抜けたりする可能性があります。

そのため、以下のように created_at だけでなく、id も並び順に含めると安定しやすくなります。

ORDER BY created_at DESC, id DESC

created_at が同じ記事が複数ある場合でも、id DESC を追加することで並び順を固定できます。

LIMITとOFFSETは整数としてバインドする

LIMITOFFSET に値を渡す場合は、PDO::PARAM_INT を指定して整数としてバインドします。

$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);

ユーザー入力をSQLに直接連結するのは危険です。

悪い例は以下です。

$sql = "SELECT * FROM posts LIMIT 10 OFFSET " . $_GET['page'];

このような書き方は避け、ページ番号を検証したうえで、プレースホルダや整数化した値を使うようにします。

PHPページネーションの完成コード

以下は、PHPとPDOを使ったページネーションの基本コードです。

<?php
$dsn = 'mysql:host=localhost;dbname=sample_db;charset=utf8mb4';
$user = 'root';
$password = '';

try {
    $pdo = new PDO($dsn, $user, $password, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
} catch (PDOException $e) {
    exit('データベース接続エラー: ' . $e->getMessage());
}

// 現在のページ番号を取得
$currentPage = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);

if ($currentPage === false || $currentPage === null || $currentPage < 1) {
    $currentPage = 1;
}

// 1ページあたりの表示件数
$perPage = 10;

// 全件数を取得
$countSql = 'SELECT COUNT(*) FROM posts';
$countStmt = $pdo->query($countSql);
$totalCount = (int) $countStmt->fetchColumn();

// 総ページ数を計算
$totalPages = (int) ceil($totalCount / $perPage);

// 存在しないページ番号への対応
if ($totalPages > 0 && $currentPage > $totalPages) {
    $currentPage = $totalPages;
}

// OFFSETを計算
$offset = ($currentPage - 1) * $perPage;

// 現在のページのデータを取得
$sql = 'SELECT id, title, body, created_at
        FROM posts
        ORDER BY created_at DESC, id DESC
        LIMIT :limit OFFSET :offset';

$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

$posts = $stmt->fetchAll();
?>

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>記事一覧<?php echo $currentPage > 1 ? ' - ' . $currentPage . 'ページ目' : ''; ?></title>
    <style>
        .pagination {
            display: flex;
            flex-wrap: wrap;
            gap: 8px;
            list-style: none;
            padding: 0;
            margin: 32px 0;
        }

        .pagination li {
            display: inline-block;
        }

        .pagination a,
        .pagination span {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 36px;
            height: 36px;
            padding: 0 10px;
            border: 1px solid #ccc;
            border-radius: 4px;
            text-decoration: none;
            color: #333;
            background: #fff;
        }

        .pagination a:hover {
            background: #f5f5f5;
        }

        .pagination .current {
            background: #333;
            color: #fff;
            border-color: #333;
            font-weight: bold;
        }

        .pagination .disabled {
            color: #aaa;
            background: #f7f7f7;
            border-color: #ddd;
        }
    </style>
</head>
<body>

<h1>記事一覧</h1>

<?php if (!empty($posts)): ?>
    <ul>
        <?php foreach ($posts as $post): ?>
            <li>
                <h2>
                    <?php echo htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8'); ?>
                </h2>
                <p>
                    <?php echo nl2br(htmlspecialchars($post['body'], ENT_QUOTES, 'UTF-8')); ?>
                </p>
                <time>
                    <?php echo htmlspecialchars($post['created_at'], ENT_QUOTES, 'UTF-8'); ?>
                </time>
            </li>
        <?php endforeach; ?>
    </ul>
<?php else: ?>
    <p>記事がありません。</p>
<?php endif; ?>

<?php if ($totalPages > 1): ?>
    <ul class="pagination">

        <?php if ($currentPage > 1): ?>
            <li>
                <a href="?page=<?php echo $currentPage - 1; ?>">前へ</a>
            </li>
        <?php else: ?>
            <li>
                <span class="disabled">前へ</span>
            </li>
        <?php endif; ?>

        <?php for ($i = 1; $i <= $totalPages; $i++): ?>
            <li>
                <?php if ($i === $currentPage): ?>
                    <span class="current"><?php echo $i; ?></span>
                <?php else: ?>
                    <a href="?page=<?php echo $i; ?>"><?php echo $i; ?></a>
                <?php endif; ?>
            </li>
        <?php endfor; ?>

        <?php if ($currentPage < $totalPages): ?>
            <li>
                <a href="?page=<?php echo $currentPage + 1; ?>">次へ</a>
            </li>
        <?php else: ?>
            <li>
                <span class="disabled">次へ</span>
            </li>
        <?php endif; ?>

    </ul>
<?php endif; ?>

</body>
</html>

このコードでは、以下の処理を行っています。

  • page パラメータから現在のページ番号を取得する
  • 不正なページ番号の場合は1ページ目にする
  • COUNT(*) で全件数を取得する
  • ceil() で総ページ数を計算する
  • OFFSET を計算する
  • LIMITOFFSET で必要なデータだけ取得する
  • 記事一覧をHTMLに表示する
  • ページ番号リンクを出力する

基本的なページネーションであれば、この形で実装できます。

ページ番号リンクを作る方法

ページネーションでは、「前へ」「次へ」「ページ番号」のリンクを出力します。

現在のページが3ページ目であれば、3だけリンクにせず、現在地として強調表示します。

<?php if ($i === $currentPage): ?>
    <span class="current"><?php echo $i; ?></span>
<?php else: ?>
    <a href="?page=<?php echo $i; ?>"><?php echo $i; ?></a>
<?php endif; ?>

現在のページをリンクにしないことで、ユーザーが今どのページを見ているのか分かりやすくなります。

1ページ目では「前へ」を無効にする

1ページ目では、前のページが存在しないため、「前へ」を無効にします。

<?php if ($currentPage > 1): ?>
    <a href="?page=<?php echo $currentPage - 1; ?>">前へ</a>
<?php else: ?>
    <span class="disabled">前へ</span>
<?php endif; ?>

最後のページでは「次へ」を無効にする

最後のページでは、次のページが存在しないため、「次へ」を無効にします。

<?php if ($currentPage < $totalPages): ?>
    <a href="?page=<?php echo $currentPage + 1; ?>">次へ</a>
<?php else: ?>
    <span class="disabled">次へ</span>
<?php endif; ?>

このようにすることで、存在しないページへのリンクを防げます。

ページ数が多い場合のページネーション

総ページ数が少ない場合は、すべてのページ番号を表示しても問題ありません。

しかし、総ページ数が多い場合、すべてのページ番号を表示すると見づらくなります。

例えば、100ページある場合、以下のように表示されてしまいます。

1 2 3 4 5 6 7 8 9 10 11 ... 100

このような場合は、現在のページ周辺だけを表示するページネーションにすると見やすくなります。

現在ページの前後だけ表示する

現在のページの前後2ページだけを表示したい場合は、以下のようにします。

$range = 2;

$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);

例えば、現在のページが10ページ目であれば、以下のような表示になります。

8 9 10 11 12

これにより、ページ数が多い場合でも、コンパクトなページネーションを作れます。

省略記号付きページネーションを作る

実務では、最初のページと最後のページを表示しつつ、途中を省略する形がよく使われます。

例えば、現在のページが10ページ目で、全部で30ページある場合は、以下のような表示です。

前へ 1 ... 8 9 10 11 12 ... 30 次へ

コード例は以下です。

<?php
$range = 2;
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
?>

<?php if ($totalPages > 1): ?>
    <ul class="pagination">

        <?php if ($currentPage > 1): ?>
            <li>
                <a href="?page=<?php echo $currentPage - 1; ?>">前へ</a>
            </li>
        <?php endif; ?>

        <?php if ($start > 1): ?>
            <li>
                <a href="?page=1">1</a>
            </li>

            <?php if ($start > 2): ?>
                <li><span>...</span></li>
            <?php endif; ?>
        <?php endif; ?>

        <?php for ($i = $start; $i <= $end; $i++): ?>
            <li>
                <?php if ($i === $currentPage): ?>
                    <span class="current"><?php echo $i; ?></span>
                <?php else: ?>
                    <a href="?page=<?php echo $i; ?>"><?php echo $i; ?></a>
                <?php endif; ?>
            </li>
        <?php endfor; ?>

        <?php if ($end < $totalPages): ?>
            <?php if ($end < $totalPages - 1): ?>
                <li><span>...</span></li>
            <?php endif; ?>

            <li>
                <a href="?page=<?php echo $totalPages; ?>"><?php echo $totalPages; ?></a>
            </li>
        <?php endif; ?>

        <?php if ($currentPage < $totalPages): ?>
            <li>
                <a href="?page=<?php echo $currentPage + 1; ?>">次へ</a>
            </li>
        <?php endif; ?>

    </ul>
<?php endif; ?>

この形式にすると、ページ数が多い一覧でも見やすくなります。

ブログ、メディアサイト、ECサイト、管理画面などで使いやすい形です。

検索条件付きページネーションの作り方

ページネーションは、検索機能と組み合わせて使うことも多いです。

例えば、以下のようなURLです。

posts.php?keyword=php&page=2

この場合、2ページ目に移動しても、keyword=php という検索条件を維持する必要があります。

検索条件を引き継がないと絞り込みが消える

ページ番号リンクを単純に以下のように作ると、検索条件が消えてしまいます。

<a href="?page=2">2</a>

このリンクでは、URLが以下のようになります。

posts.php?page=2

検索キーワードである keyword=php が消えてしまうため、検索結果の2ページ目ではなく、通常の記事一覧の2ページ目が表示されてしまいます。

http_build_queryで検索条件を維持する

検索条件を維持したままページ番号だけ変更するには、http_build_query() を使うと便利です。

function buildPageUrl($page) {
    $params = $_GET;
    $params['page'] = $page;

    return '?' . http_build_query($params);
}

この関数を使うと、現在のURLパラメータを維持したまま、page の値だけ変更できます。

例えば、現在のURLが以下だったとします。

posts.php?keyword=php&category=programming&page=1

このとき、buildPageUrl(2) を実行すると、以下のようなURLを生成できます。

?keyword=php&category=programming&page=2

ページネーションリンクでは、以下のように使います。

<a href="<?php echo htmlspecialchars(buildPageUrl($i), ENT_QUOTES, 'UTF-8'); ?>">
    <?php echo $i; ?>
</a>

URLをHTMLに出力するときも、htmlspecialchars() でエスケープしておくと安全です。

検索付きページネーションのSQL

検索キーワードを使って記事を絞り込む場合は、件数取得用のSQLとデータ取得用のSQLの両方に同じ条件を指定します。

検索キーワードを取得する

まず、検索キーワードを取得します。

$keyword = isset($_GET['keyword']) ? trim($_GET['keyword']) : '';

件数取得のSQL

検索キーワードがある場合は、WHERE 条件を付けて件数を取得します。

if ($keyword !== '') {
    $countSql = 'SELECT COUNT(*) FROM posts WHERE title LIKE :keyword';
    $countStmt = $pdo->prepare($countSql);
    $countStmt->bindValue(':keyword', '%' . $keyword . '%', PDO::PARAM_STR);
    $countStmt->execute();
} else {
    $countSql = 'SELECT COUNT(*) FROM posts';
    $countStmt = $pdo->query($countSql);
}

$totalCount = (int) $countStmt->fetchColumn();

データ取得のSQL

データ取得側でも、件数取得と同じ検索条件を使います。

if ($keyword !== '') {
    $sql = 'SELECT id, title, body, created_at
            FROM posts
            WHERE title LIKE :keyword
            ORDER BY created_at DESC, id DESC
            LIMIT :limit OFFSET :offset';

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':keyword', '%' . $keyword . '%', PDO::PARAM_STR);
} else {
    $sql = 'SELECT id, title, body, created_at
            FROM posts
            ORDER BY created_at DESC, id DESC
            LIMIT :limit OFFSET :offset';

    $stmt = $pdo->prepare($sql);
}

$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

$posts = $stmt->fetchAll();

検索付きページネーションでは、件数取得のSQLとデータ取得のSQLで同じWHERE条件を使うことが重要です。

ここがずれていると、総ページ数と実際の検索結果が一致しなくなります。

LIKE検索のワイルドカードに注意する

LIKE 検索では、%_ が特別な意味を持ちます。

記号意味
%任意の文字列
_任意の1文字

そのため、ユーザーが入力した %_ を通常の文字として検索したい場合は、エスケープ処理が必要です。

より実務的に対応するなら、以下のような関数を用意します。

function escapeLike($value) {
    return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value);
}

SQLでは ESCAPE を指定します。

$sql = 'SELECT id, title, body, created_at
        FROM posts
        WHERE title LIKE :keyword ESCAPE "\\"
        ORDER BY created_at DESC, id DESC
        LIMIT :limit OFFSET :offset';

バインドする値は以下のように作ります。

$keywordForLike = '%' . escapeLike($keyword) . '%';

$stmt->bindValue(':keyword', $keywordForLike, PDO::PARAM_STR);

簡単な検索機能であれば必須ではありませんが、より正確な検索を実装したい場合は覚えておくとよいでしょう。

ページネーションを関数化する方法

ページネーション部分は複数の画面で使うことがあるため、関数化しておくと便利です。

URL生成用の関数

まず、現在のクエリパラメータを維持したまま、ページ番号だけ変更する関数を作ります。

function buildPageUrl($page) {
    $params = $_GET;
    $params['page'] = $page;

    return '?' . http_build_query($params);
}

ページネーションHTMLを出力する関数

次に、ページネーションのHTMLを出力する関数を作ります。

function renderPagination($currentPage, $totalPages) {
    if ($totalPages <= 1) {
        return;
    }

    $range = 2;
    $start = max(1, $currentPage - $range);
    $end = min($totalPages, $currentPage + $range);

    echo '<ul class="pagination">';

    if ($currentPage > 1) {
        echo '<li><a href="' . htmlspecialchars(buildPageUrl($currentPage - 1), ENT_QUOTES, 'UTF-8') . '">前へ</a></li>';
    } else {
        echo '<li><span class="disabled">前へ</span></li>';
    }

    if ($start > 1) {
        echo '<li><a href="' . htmlspecialchars(buildPageUrl(1), ENT_QUOTES, 'UTF-8') . '">1</a></li>';

        if ($start > 2) {
            echo '<li><span>...</span></li>';
        }
    }

    for ($i = $start; $i <= $end; $i++) {
        if ($i === $currentPage) {
            echo '<li><span class="current">' . $i . '</span></li>';
        } else {
            echo '<li><a href="' . htmlspecialchars(buildPageUrl($i), ENT_QUOTES, 'UTF-8') . '">' . $i . '</a></li>';
        }
    }

    if ($end < $totalPages) {
        if ($end < $totalPages - 1) {
            echo '<li><span>...</span></li>';
        }

        echo '<li><a href="' . htmlspecialchars(buildPageUrl($totalPages), ENT_QUOTES, 'UTF-8') . '">' . $totalPages . '</a></li>';
    }

    if ($currentPage < $totalPages) {
        echo '<li><a href="' . htmlspecialchars(buildPageUrl($currentPage + 1), ENT_QUOTES, 'UTF-8') . '">次へ</a></li>';
    } else {
        echo '<li><span class="disabled">次へ</span></li>';
    }

    echo '</ul>';
}

呼び出し側は、以下のようにシンプルにできます。

<?php renderPagination($currentPage, $totalPages); ?>

関数化しておけば、記事一覧、商品一覧、検索結果一覧などで再利用しやすくなります。

ページネーションのCSS

ページネーションは、最低限のCSSを設定しておくと見やすくなります。

.pagination {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    list-style: none;
    padding: 0;
    margin: 32px 0;
}

.pagination li {
    display: inline-block;
}

.pagination a,
.pagination span {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 36px;
    height: 36px;
    padding: 0 10px;
    border: 1px solid #ccc;
    border-radius: 4px;
    text-decoration: none;
    color: #333;
    background: #fff;
}

.pagination a:hover {
    background: #f5f5f5;
}

.pagination .current {
    background: #333;
    color: #fff;
    border-color: #333;
    font-weight: bold;
}

.pagination .disabled {
    color: #aaa;
    background: #f7f7f7;
    border-color: #ddd;
}

flex-wrap: wrap; を指定しておくと、スマートフォンなど画面幅が狭い環境でもページ番号が折り返されます。

ページネーションで注意すべきセキュリティ対策

PHPでページネーションを実装する際は、セキュリティ面にも注意が必要です。

pageパラメータをそのまま使わない

page パラメータはURLから送られてくる値です。

ユーザーが自由に変更できるため、そのままSQLに使ってはいけません。

悪い例です。

$page = $_GET['page'];
$offset = ($page - 1) * 10;

安全に扱うなら、以下のように整数として検証します。

$currentPage = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT);

if ($currentPage === false || $currentPage === null || $currentPage < 1) {
    $currentPage = 1;
}

SQLインジェクション対策をする

SQLに外部からの値を渡す場合は、プリペアドステートメントを使います。

$stmt = $pdo->prepare($sql);
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

検索キーワードを使う場合も同様です。

$stmt->bindValue(':keyword', '%' . $keyword . '%', PDO::PARAM_STR);

ユーザー入力をSQL文字列に直接連結するのは避けましょう。

HTML出力時はエスケープする

データベースから取得した値をHTMLに表示するときは、htmlspecialchars() を使います。

echo htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8');

本文の改行を反映したい場合は、以下のようにします。

echo nl2br(htmlspecialchars($post['body'], ENT_QUOTES, 'UTF-8'));

この順番で書くことで、HTMLタグを無効化しつつ、改行を <br> に変換できます。

URL出力時もエスケープする

ページネーションリンクのURLを出力するときも、htmlspecialchars() を使います。

<a href="<?php echo htmlspecialchars(buildPageUrl($i), ENT_QUOTES, 'UTF-8'); ?>">
    <?php echo $i; ?>
</a>

URLもHTMLの属性値として出力されるため、エスケープしておくと安全です。

公開ページでページネーションを使うときの注意点

ブログ、メディアサイト、商品一覧ページなど、外部からアクセスされるページでページネーションを使う場合は、ユーザーにも検索エンジンにもページ構造が伝わりやすいように設計することが大切です。

各ページに固有URLを持たせる

ページネーションされた一覧ページでは、各ページに固有のURLを持たせます。

例えば、以下のようなURLです。

/posts.php?page=1
/posts.php?page=2
/posts.php?page=3

検索エンジンが各ページをたどれるように、JavaScriptだけで切り替えるのではなく、通常の a タグでリンクを設置することが大切です。

<a href="/posts.php?page=2">2</a>

通常のリンクとしてページ番号を設置しておけば、ユーザーも検索エンジンも各ページへ移動しやすくなります。

canonicalは各ページを自己参照にする

ページネーションされたページでは、各ページに異なるデータが表示されます。

そのため、基本的には各ページを自己参照canonicalにします。

2ページ目であれば、2ページ目自身をcanonicalにします。

<link rel="canonical" href="https://example.com/posts.php?page=2">

3ページ目であれば、3ページ目自身をcanonicalにします。

<link rel="canonical" href="https://example.com/posts.php?page=3">

避けたいのは、2ページ目や3ページ目のcanonicalをすべて1ページ目に向けることです。

<!-- 避けたい例 -->
<link rel="canonical" href="https://example.com/posts.php">

このようにすると、2ページ目以降を1ページ目の重複に近いページとして伝えてしまう可能性があります。

ページごとに表示される記事や商品が異なる場合は、それぞれのページを独立したURLとして扱う方が自然です。

rel=”prev”とrel=”next”に頼りすぎない

以前は、ページネーションの前後関係を示すために、以下のようなタグが使われることがありました。

<link rel="prev" href="https://example.com/posts.php?page=1">
<link rel="next" href="https://example.com/posts.php?page=3">

HTMLとして使うこと自体はできますが、これらのタグに頼りすぎる必要はありません。

ページネーションで重要なのは、以下のような基本です。

  • 各ページに固有URLを持たせる
  • 通常の a タグでページ番号リンクを設置する
  • 各ページに自己参照canonicalを設定する
  • 存在しないページ番号は適切に処理する
  • ページごとの表示内容が重複しすぎないようにする

特に、通常のリンクでページをたどれるようにしておくことが重要です。

ページタイトルにページ番号を入れる

2ページ目以降では、ページタイトルにページ番号を含めると、ユーザーにもページの違いが分かりやすくなります。

<title>記事一覧<?php echo $currentPage > 1 ? ' - ' . $currentPage . 'ページ目' : ''; ?></title>

出力例は以下です。

記事一覧
記事一覧 - 2ページ目
記事一覧 - 3ページ目

ページタイトルを分けることで、各ページの違いが伝わりやすくなります。

大量データではOFFSETが重くなる場合がある

LIMITOFFSET を使ったページネーションは実装しやすく、一般的な一覧ページではよく使われます。

ただし、データ件数が非常に多い場合、後ろのページほど処理が重くなることがあります。

例えば、以下のようなSQLです。

SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 100000;

この場合、データベースは大量の行を読み飛ばしてから、必要な10件を取得します。

数千件から数万件程度であれば大きな問題にならないこともありますが、数十万件、数百万件規模のデータを扱う場合は注意が必要です。

カーソルページネーションという方法もある

大量データを扱う場合は、OFFSET を使わず、前回取得した最後のデータを基準に次のデータを取得する方法があります。

これをカーソルページネーション、またはキセットページネーションと呼ぶことがあります。

id順で取得するシンプルな例

例えば、id の降順で記事を表示する場合、最初のページは以下のように取得します。

SELECT id, title, created_at
FROM posts
ORDER BY id DESC
LIMIT 10;

次のページでは、前回表示した最後の記事IDより小さいデータを取得します。

SELECT id, title, created_at
FROM posts
WHERE id < :last_id
ORDER BY id DESC
LIMIT 10;

PHPでは以下のように書けます。

$lastId = filter_input(INPUT_GET, 'last_id', FILTER_VALIDATE_INT);
$perPage = 10;

if ($lastId) {
    $sql = 'SELECT id, title, created_at
            FROM posts
            WHERE id < :last_id
            ORDER BY id DESC
            LIMIT :limit';

    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
} else {
    $sql = 'SELECT id, title, created_at
            FROM posts
            ORDER BY id DESC
            LIMIT :limit';

    $stmt = $pdo->prepare($sql);
}

$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->execute();

$posts = $stmt->fetchAll();

次ページへのリンクは、現在表示している最後の記事IDを使います。

<?php if (!empty($posts)): ?>
    <?php $lastPost = end($posts); ?>
    <a href="?last_id=<?php echo htmlspecialchars($lastPost['id'], ENT_QUOTES, 'UTF-8'); ?>">次へ</a>
<?php endif; ?>

この方法は、後ろのページに進んでも処理が重くなりにくいのがメリットです。

created_atで並べる場合は条件に注意する

カーソルページネーションで注意したいのは、並び順に使っているカラムに合わせて条件を作る必要があることです。

例えば、以下のように並べている場合を考えます。

ORDER BY created_at DESC, id DESC

この場合、単純に id < :last_id とするだけでは正確ではありません。

created_atid の両方を使って、以下のように条件を作る必要があります。

SELECT id, title, created_at
FROM posts
WHERE created_at < :last_created_at
   OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
LIMIT :limit;

このように、カーソルページネーションでは、並び順と取得条件を一致させることが重要です。

OFFSET方式とカーソル方式の違い

OFFSET 方式とカーソル方式には、それぞれメリットとデメリットがあります。

項目OFFSET方式カーソル方式
実装のしやすさ簡単やや複雑
ページ番号表示しやすいしにくい
「3ページ目へ移動」しやすいしにくい
大量データでの速度遅くなりやすい速い
無限スクロールやや不向き向いている

通常のブログや管理画面であれば、LIMITOFFSET を使ったページネーションで十分なことが多いです。

一方、SNSのタイムライン、チャット履歴、大規模な商品一覧などでは、カーソルページネーションも検討するとよいでしょう。

WordPressでページネーションを作る方法

WordPressでページネーションを作る場合は、自分でSQLを書くよりも、WordPressの関数を使うのが一般的です。

通常の投稿一覧では、the_posts_pagination() を使えます。

通常の投稿一覧でページネーションを表示する

通常のメインクエリであれば、以下のようにページネーションを表示できます。

<?php
the_posts_pagination([
    'mid_size'  => 2,
    'prev_text' => '前へ',
    'next_text' => '次へ',
]);
?>

mid_size は、現在のページ番号の左右に表示するページ番号の数です。

WP_Queryでページネーションを作る

カスタムクエリで投稿一覧を作る場合は、WP_Querypaged を使います。

$paged = get_query_var('paged') ? get_query_var('paged') : 1;

$args = [
    'post_type'      => 'post',
    'posts_per_page' => 10,
    'paged'          => $paged,
];

$query = new WP_Query($args);

投稿を表示するループは以下です。

<?php if ($query->have_posts()): ?>
    <?php while ($query->have_posts()): $query->the_post(); ?>
        <article>
            <h2><?php the_title(); ?></h2>
            <div><?php the_excerpt(); ?></div>
        </article>
    <?php endwhile; ?>
<?php else: ?>
    <p>記事がありません。</p>
<?php endif; ?>

ページネーションは、paginate_links() を使って出力します。

<?php
echo paginate_links([
    'total'   => $query->max_num_pages,
    'current' => $paged,
    'prev_text' => '前へ',
    'next_text' => '次へ',
]);
?>

最後に、カスタムクエリを使った場合は wp_reset_postdata() を実行します。

<?php wp_reset_postdata(); ?>

固定ページ内ではpageの扱いに注意する

WordPressの固定ページ内でカスタムクエリを使う場合、環境によっては paged ではなく page を確認する必要があります。

うまくページ番号が取得できない場合は、以下のような書き方も検討します。

$paged = get_query_var('paged') ? get_query_var('paged') : get_query_var('page');
$paged = $paged ? $paged : 1;

ただし、通常のアーカイブページや投稿一覧ページでは、基本的に paged を使えば問題ありません。

PHPページネーションでよくあるミス

PHPでページネーションを作るときは、いくつか間違いやすいポイントがあります。

OFFSETの計算を間違える

よくあるミスが、OFFSET の計算です。

間違った例です。

$offset = $currentPage * $perPage;

この場合、1ページ目の OFFSET が10になってしまい、最初の10件が表示されません。

正しくは以下です。

$offset = ($currentPage - 1) * $perPage;

総ページ数の計算でceilを使っていない

総ページ数を計算するときは、ceil() を使う必要があります。

間違った例です。

$totalPages = $totalCount / $perPage;

例えば、95件を10件ずつ表示する場合、本来は10ページ必要です。

しかし、単純に割り算しただけでは 9.5 になってしまいます。

正しくは以下です。

$totalPages = (int) ceil($totalCount / $perPage);

ORDER BYを指定していない

ページネーションでは、必ず ORDER BY を指定します。

悪い例です。

SELECT *
FROM posts
LIMIT 10 OFFSET 0;

このように並び順を指定しないと、ページごとの表示順が安定しない可能性があります。

正しくは以下のようにします。

SELECT id, title, body, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;

検索条件をページリンクに引き継いでいない

検索結果ページでありがちなミスです。

現在のURLが以下だったとします。

search.php?keyword=php&page=1

この状態で、2ページ目のリンクが以下になっているとします。

search.php?page=2

これでは、検索キーワードが消えてしまいます。

検索条件を維持するには、http_build_query() を使って、既存のクエリパラメータを引き継ぐようにします。

$params = $_GET;
$params['page'] = $page;
$url = '?' . http_build_query($params);

出力時のエスケープを忘れる

データベースの値やURLパラメータをHTMLに出力するときは、エスケープが必要です。

echo htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8');

ページネーションのURLも同様です。

echo htmlspecialchars(buildPageUrl($i), ENT_QUOTES, 'UTF-8');

エスケープを忘れると、XSSの原因になる可能性があります。

PHPでページネーションを作るときのまとめ

PHPでページネーションを作る基本は、現在のページ番号をもとに、必要なデータだけを取得することです。

基本の計算式は以下です。

$offset = ($currentPage - 1) * $perPage;

総ページ数は、全件数を1ページあたりの表示件数で割り、ceil() で切り上げます。

$totalPages = (int) ceil($totalCount / $perPage);

SQLでは、LIMITOFFSET を使って、現在のページに必要なデータだけを取得します。

SELECT id, title, body, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 0;

実装時に特に注意したいのは、以下のポイントです。

  • page パラメータは必ず整数として検証する
  • OFFSET の計算式を間違えない
  • COUNT(*) で全件数を取得する
  • ORDER BY で並び順を固定する
  • SQLインジェクション対策をする
  • HTML出力時は htmlspecialchars() を使う
  • 検索条件がある場合はページリンクに引き継ぐ
  • ページ数が多い場合は省略記号付きページネーションにする
  • 公開ページではURL設計や存在しないページ番号の扱いにも注意する
  • 大量データではカーソルページネーションも検討する

PHPのページネーションは、基本の考え方を押さえれば難しくありません。

まずは LIMITOFFSETCOUNT(*)ceil() の関係を理解し、そこにセキュリティ対策や公開ページ向けの設計を加えていくと、実務でも使いやすいページネーションを作れます。

以上、PHPでページネーションを作る方法についてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次