1. 今回の学習ゴールと練習課題(仕様)
あなたは小さなネットショップの商品一覧ページを任されました。商品が増えてきたので、1ページに全部並べるのをやめてページ分け(ページネーション)したい、という依頼です。
お題の仕様
- 商品データは23件(
商品01〜商品23、価格は100円〜2,300円)。今回は配列で用意する - 1ページに5件ずつ表示する(総ページ数は5ページになる)
- 表示するページ番号は URL の
?page=N(GETパラメータ)で受け取る ?pageが未指定・数字でない・0以下・小数・配列のときは1ページ目を表示する- 総ページ数より大きい番号が来たら最終ページを表示する
- 「全23件中 6〜10件を表示(2 / 5ページ)」のような案内文を出す
- 「« 前へ」「次へ »」とページ番号リンクを出す。現在のページはリンクにしない。1ページ目では「前へ」、最終ページでは「次へ」を出さない
- Warning・Deprecated・Fatal error を一切出さないこと
期待される実行結果(?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
何がダメなのか
- ① 未定義キーの直接参照:トップページに
?pageは付いていないため Warning。値はnullになり、-5番目から存在しない添字を読みに行きます。 - ② 文字列のまま計算:
$_GETの値は常に文字列か配列です。PHP 8 では数字でない文字列や配列との計算は TypeError で停止します。 - ③ 範囲チェックなし:3件しかない最終ページでも5回ループし、24・25件目を読みに行きます。
- ④ 割り算をそのまま総ページ数に:
23 / 5 = 4.6なのでループは4で止まり、5ページ目へのリンクが消えます。 - ⑤ 入力値をそのまま出力:
?page=1<script>…のように先頭が数字なら処理が続き、タグがそのまま出力されます。典型的な反射型XSSです。
「エラー表示を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. もう一歩進める!発展チャレンジ課題
- チャレンジ1:キーワード検索と両立
?q=商品1&page=2の検索語でstr_contains()絞り込みをしてからページ分けします。ページリンクで検索語が消えないよう、pageUrl($p, ['q' => $q])のように検索パラメータを引き継ぐ設計に改良しましょう(※生の$_GETを丸ごとマージするのではなく、許可したキーのみを渡すのが現場の作法です)。 - チャレンジ2:ページ番号の省略表示
40ページになったら「1 … 8 9 [10] 11 12 … 40」のように、現在ページの前後2件と先頭・末尾だけを出す関数を作ります。 - チャレンジ3:表示件数の切り替え
?per=10を受け付けます。ただしin_array($value, [5, 10, 20], true)のホワイトリスト判定で、それ以外は5件に戻すこと。 - チャレンジ4:PDO版に書き換え(SQLの型に注意)
SELECT COUNT(*)で総件数を算出し、LIMIT :limit OFFSET :offsetで必要な行だけを取得します。ここで重要な落とし穴として、PDOの静的プレースホルダ設定(ATTR_EMULATE_PREPARES => false)でexecute([':limit' => $perPage])と配列渡しをすると文字列型('5')としてバインドされ、MySQLのバージョンによって構文エラー(Syntax error)になります。必ず$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT)のように明示的に型を指定してバインドしましょう。
まとめ
ページネーションには「GET パラメータは文字列か配列でしか来ない」「範囲外の値は必ず来る」「割り算の端数」「出力時のエスケープ」という Web の基本が詰まっています。filter_var で入口を固め、min と ceil で範囲を整え、array_slice で切り出す。ヘタレな私も、一覧ページを作るたびにこの順番を指差し確認しています。