1. 今回の学習ゴールと練習課題(仕様)

あなたは小さなネットショップの商品一覧ページを任されました。商品が増えてきたので、1ページに全部並べるのをやめてページ分け(ページネーション)したい、という依頼です。

お題の仕様

期待される実行結果(?page=2 のとき)

全23件中 6〜10件を表示(2 / 5ページ)

・商品06(600円)
・商品07(700円)
・商品08(800円)
・商品09(900円)
・商品10(1,000円)

« 前へ  1  [2]  3  4  5  次へ »

不正な入力に対する期待値

?page=abc   → 1ページ目(1〜5件)
?page=2abc  → 1ページ目
?page=1.5   → 1ページ目
?page=0     → 1ページ目
?page[]=1   → 1ページ目
?page=999   → 5ページ目(21〜23件)

2. 初心者がハマるNGコード(Before)と解説

まずは、私自身も新人時代に書いて先輩に無言で差し戻された「動いてるっぽい」コードです。?page=2 を手で入れて確認しただけで満足すると、こうなります。

<?php
$products = [];
for ($i = 1; $i <= 23; $i++) {
    $products[] = ['name' => sprintf('商品%02d', $i), 'price' => 100 * $i];
}

$perPage = 5;
$page = $_GET['page'];                 // ① いきなり直接参照
$start = ($page - 1) * $perPage;       // ② 文字列のまま計算

for ($i = $start; $i < $start + $perPage; $i++) {
    echo $products[$i]['name'] . '<br>';   // ③ 範囲チェックなし
}

$totalPages = count($products) / $perPage; // ④ 23 / 5 = 4.6
for ($p = 1; $p <= $totalPages; $p++) {
    echo "<a href='?page=$p'>$p</a> ";
}

echo "現在 {$_GET['page']} ページ目です";   // ⑤ そのまま出力

PHP 8.4 で実際にいろいろな URL を叩いてみた結果がこちらです。

(未指定)   Warning: Undefined array key "page"
           Warning: Undefined array key -5 … -1(5回)
?page=5    Warning: Undefined array key 23 / 24
?page=abc  Fatal error: Uncaught TypeError: Unsupported operand types: string - int
?page=2abc Warning: A non-numeric value encountered
?page=1.5  Deprecated: Implicit conversion from float 2.5 to int loses precision
?page[]=1  Fatal error: Uncaught TypeError: Unsupported operand types: array - int

何がダメなのか

「エラー表示をOFFにすれば見えない」は解決ではありません。ログは Warning で埋まり、XSS の穴は開いたままです。

3. コピペで即動く模範解答コード(After)

PHP 8.3 / 8.4 で動作確認済みです。pagination.php として保存し、同じフォルダで php -S localhost:8000 を実行してから http://localhost:8000/pagination.php?page=2 を開いてみてください。

<?php
declare(strict_types=1);

const PER_PAGE   = 5; // 1ページあたりの表示件数
const FIRST_PAGE = 1; // 不正な入力のときにフォールバックするページ

// ダミー商品データ(実務ではDBから取得する想定)
$products = [];
for ($i = 1; $i <= 23; $i++) {
    $products[] = ['name' => sprintf('商品%02d', $i), 'price' => 100 * $i];
}

/** HTMLエスケープの短縮関数 */
function h(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}

/** 総ページ数を求める(0件でも最低1ページ) */
function calcTotalPages(int $totalItems, int $perPage): int
{
    return max(1, (int) ceil($totalItems / $perPage));
}

/** 外部入力のページ番号を「1〜最終ページ」の整数に正規化する */
function resolveCurrentPage(mixed $rawPage, int $totalPages): int
{
    // 未指定(null)や ?page[]=1 のような配列型は、入口で1ページ目に倒す
    if (!is_string($rawPage)) {
        return FIRST_PAGE;
    }

    // 整数として正しく、かつ1以上のときだけ採用。それ以外は default を返す
    $page = filter_var($rawPage, FILTER_VALIDATE_INT, [
        'options' => ['default' => FIRST_PAGE, 'min_range' => FIRST_PAGE],
    ]);

    return min($page, $totalPages);
}

/**
 * 指定ページ分の要素と表示範囲を返す
 * @return array{items: array, from: int, to: int}
 */
function paginate(array $items, int $page, int $perPage): array
{
    $offset    = ($page - 1) * $perPage;
    $pageItems = array_slice($items, $offset, $perPage);

    return [
        'items' => $pageItems,
        'from'  => $pageItems === [] ? 0 : $offset + 1,
        'to'    => $offset + count($pageItems),
    ];
}

/** ページ番号付きのURL(クエリ文字列)を組み立てる */
function pageUrl(int $page, array $params = []): string
{
    $params['page'] = $page;
    return '?' . http_build_query($params);
}

$totalItems  = count($products);
$totalPages  = calcTotalPages($totalItems, PER_PAGE);
$currentPage = resolveCurrentPage($_GET['page'] ?? null, $totalPages);
$result      = paginate($products, $currentPage, PER_PAGE);
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>商品一覧(<?= $currentPage ?>ページ目)</title>
  <style>
    body { font-family: sans-serif; line-height: 1.6; padding: 24px; max-width: 640px; margin: 0 auto; color: #333; }
    ul { padding-left: 20px; margin: 16px 0; }
    li { margin-bottom: 6px; }
    nav a, nav strong { display: inline-block; padding: 4px 10px; margin: 0 2px; text-decoration: none; border-radius: 4px; }
    nav a { background: #f0f0f0; color: #0066cc; }
    nav strong { background: #0066cc; color: #fff; }
  </style>
</head>
<body>
  <?php if ($totalItems === 0): ?>
    <p>該当する商品は見つかりませんでした。</p>
  <?php else: ?>
    <p>全<?= $totalItems ?>件中 <?= $result['from'] ?>〜<?= $result['to'] ?>件を表示(<?= $currentPage ?> / <?= $totalPages ?>ページ)</p>

    <ul>
    <?php foreach ($result['items'] as $product): ?>
      <li><?= h($product['name']) ?>(<?= number_format($product['price']) ?>円)</li>
    <?php endforeach; ?>
    </ul>

    <nav aria-label="ページ送り">
    <?php if ($currentPage > 1): ?>
      <a href="<?= h(pageUrl($currentPage - 1)) ?>">« 前へ</a>
    <?php endif; ?>

    <?php for ($p = 1; $p <= $totalPages; $p++): ?>
      <?php if ($p === $currentPage): ?>
        <strong aria-current="page"><?= $p ?></strong>
      <?php else: ?>
        <a href="<?= h(pageUrl($p)) ?>"><?= $p ?></a>
      <?php endif; ?>
    <?php endfor; ?>

    <?php if ($currentPage < $totalPages): ?>
      <a href="<?= h(pageUrl($currentPage + 1)) ?>">次へ »</a>
    <?php endif; ?>
    </nav>
  <?php endif; ?>
</body>
</html>

先ほどの NG パターン(未指定・abc・2abc・1.5・0・999・page[]=1・スクリプトタグ入り)をすべて流しても、Warning は1行も出ず、仕様どおり1ページ目か最終ページが表示されます。

4. 現場のプロ(kixking)による徹底コード解説

Step 1:入口で「1以上の整数か?」を一度だけ判定する

心臓部は resolveCurrentPage() です。$_GET['page'] ?? null で未定義キーの Warning を回避し、is_string() で null や配列(?page[]=1 など)を入口で弾いています。

実は filter_var() はスカラー値の検証を前提とした関数です。配列を渡すと、FILTER_REQUIRE_ARRAY フラグを指定しない限りバリデーション結果ではなく関数レベルで一律 false を返します。そのため、いくら 'default' => FIRST_PAGE を指定していても無視されて false が返り、戻り値の int 型宣言違反(TypeError)でスクリプトがクラッシュしてしまいます。この is_string() の1行は、その事故を未然に防ぐ決定的な防壁です。

また、is_numeric('2abc') は false を返しますが、intval('2abc') は 2 に丸めてしまいます。一方 FILTER_VALIDATE_INT は "2abc" や "1.5"、"1e3" などを厳格に不正と判定して default に倒してくれます。「なんとなく数字に寄せる」のではなく「正しくなければ即座に既定値」とするのが現場のセキュリティ標準です。

Step 2:上限は min()、総ページ数は ceil() で切り上げ

min($page, $totalPages) で ?page=999 を最終ページに丸めれば、謎の空ページは生まれません。総ページ数は ceil(23 / 5) の戻り値が float(5.0)なので、(int) で整数型へ明示的にキャストします。max(1, …) は商品0件時の保険で、総ページ数が 0 になりオフセットがマイナス(-5)になるのを確実に防止します。

Step 3:添字ループをやめて array_slice()

array_slice($items, $offset, $perPage) は「$offset 番目から最大 $perPage 件」を切り出し、残りが3件なら3件だけ返します。NG コードの③(配列の添字オーバー)が丸ごと消え去ります。

【現場のシニアからの警告:実務DBでの「全件配列フェッチ」は厳禁!】
今回は PHP の配列操作と計算アルゴリズムを体で覚えるための「配列ドリル」として array_slice() を使っています。しかし実務でデータベース(MySQL等)を扱う際は、絶対に「DBから全件 SELECT して配列に入れてから array_slice()」を行ってはいけません。データが数万件・数十万件になった瞬間に Web サーバーのメモリ上限(memory_limit)を食い尽くし、Fatal error: Allowed memory size exhausted でサーバー全体がダウンします。DB連携時は、必ず後述のチャレンジ4のように SQL の LIMIT / OFFSET を用いて、必要な5件だけをDB側で絞り込んで取得してください。

Step 4:生の $_GET を出力する行をゼロにする

商品名は h() でエスケープし、URL は http_build_query() で安全にエンコードした上で、HTML属性値として h() を通します。画面に出力されるのは安全に検証済みの整数とエスケープ済みの文字列のみ。「生の $_GET を直接 echo する行が1行もない」ことが、コードレビューで最初に見られる品質基準です。計算処理を純粋関数に切り出しているため、PHPUnit 等での自動テストも極めて容易です。

5. もう一歩進める!発展チャレンジ課題

まとめ

ページネーションには「GET パラメータは文字列か配列でしか来ない」「範囲外の値は必ず来る」「割り算の端数」「出力時のエスケープ」という Web の基本が詰まっています。filter_var で入口を固め、min と ceil で範囲を整え、array_slice で切り出す。ヘタレな私も、一覧ページを作るたびにこの順番を指差し確認しています。