はじめに
PHPでレビュー投稿フォームを実装していたとき、評価スコア(1〜5の整数)のバリデーションに思いがけず苦労しました。
「type="number" で送ったんだから、数値として受け取れるでしょ」と思いきや、PHPはそう甘くない。この記事では、その落とし穴と、安全なバリデーションを実現するまでの思考プロセスを共有します。
フォームのHTML
<label for="score">評価(5点満点の整数)</label>
<input type="number" id="score" name="score" min="1" max="5">
一見シンプルなフォームです。type="number" で min・max も指定しているし、「1〜5の整数しか送れないでしょ」と思うかもしれません。
しかし、この認識には 2つの落とし穴 があります。
落とし穴① $_POST の値はすべて文字列になる
HTMLの type="number" はあくまでブラウザのUI上の制御であり、PHPの$_POST(スーパーグローバル変数)で受け取った値は すべて文字列型(string) として扱われます。
// 実際に受け取る値のイメージ
$_POST['score'] = "3"; // 数値の 3 ではなく、文字列の "3"
なので、以下のような判定は危険です。
// ❌ 危険な例
if ((int)$_POST['score'] >= 1 && (int)$_POST['score'] <= 5) {
// OK扱いにしてしまう
}
intによる型キャストは "3abc" のような文字列でも 3 に変換してしまいます。つまり 不正な入力をすり抜けさせてしまう 可能性があります。
落とし穴② min・max はブラウザでしか効かない
<input type="number" min="1" max="5"> の属性は、ブラウザ側のUI制限に過ぎません。
開発者ツールでHTMLを書き換えれば簡単に突破できます。
バリデーションは 必ずサーバーサイド(PHP側)でも行う 必要があります。
安全なバリデーションの実装
これらの落とし穴を踏まえて実装したコードが以下です。
if (!strlen($review['score'])) {
$errors['score'] = '評価を入力してください';
} elseif (!ctype_digit($review['score'])) {
$errors['score'] = '評価は数値で入力してください';
} else {
$score = (int)$review['score'];
if ($score < 1 || $score > 5)
$errors['score'] = '評価は1~5の数値で入力してください';
}
3段階のチェックになっています。順番に解説します。
Step 1: 空文字チェック
if (!strlen($review['score'])) {
$errors['score'] = '評価を入力してください';
}
strlen() で文字列の長さを取得し、0(空文字)であればエラーにします。
未入力の場合は $_POST['score'] が空文字 "" になるため、最初にこれを弾いておきます。
Step 2: ctype_digit() で「全文字が数字か」を確認
} elseif (!ctype_digit($review['score'])) {
$errors['score'] = '評価は数値で入力してください';
}
ここが今回のキモです。
ctype_digit() は、文字列に含まれるすべての文字が十進数の数字(0〜9)であるかを確認する関数です。
| 入力 |
ctype_digit() の結果 |
|---|---|
"3" |
true ✅ |
"3abc" |
false ❌ |
"3.0" |
false ❌(. が含まれるため) |
"-1" |
false ❌(- が含まれるため) |
"" |
false ❌ |
(int) キャストと違い、"3abc" のような曖昧な文字列をきちんと弾いてくれるのが強みです。
この実装のヒントになったのが、uzullaさんの以下の記事です。
記事の中で ctype_digit と is_numeric の使い分けが丁寧に解説されており、「シンプルで読みやすく、かつ高速に判定できる」方法として紹介されています。
Step 3: 型キャストして範囲チェック
} else {
$score = (int)$review['score'];
if ($score < 1 || $score > 5)
$errors['score'] = '評価は1~5の数値で入力してください';
}
Step 2 を通過した時点で、値は「数字のみで構成された文字列」であることが保証されています。
ここで型キャストを使って整数に変換し、1〜5の範囲内かどうかを確認します。
Step 2 のフィルタリングがあるからこそ、このキャストが安全に使えます。
なぜ (int) だけじゃダメなのか、改めて整理
// ❌ これだけでは不十分
$score = (int)$_POST['score'];
if ($score < 1 || $score > 5) {
// エラー
}
たとえば "3abc" という入力が来たとき:
-
(int)"3abc"→3(PHPは先頭から数値として読める部分だけ変換する) - 結果、
3は1〜5の範囲内なのでエラーにならない ← バグ!
ctype_digit() を先に通すことで、このすり抜けを防げます。
まとめ
| チェック | 目的 | 使用関数 |
|---|---|---|
| 空文字チェック | 未入力を弾く | strlen() |
| 文字種チェック | 数字以外(記号・アルファベット・小数点等)を弾く | ctype_digit() |
| 範囲チェック | 数値として1〜5の範囲かを確認する | (int) キャスト |
ポイントをまとめると:
-
type="number"は見た目の制御に過ぎない。サーバー側で必ずバリデーションを行う -
$_POSTの値はすべて文字列。(int)キャストだけでは不正値をすり抜けさせてしまう -
ctype_digit()で「数字のみで構成されているか」を先にチェックすることで、安全な整数バリデーションが実現できる
おわりに
現在、エンジニアへの転職を目指して勉強中です。バリデーション一つでこんなに奥が深いとは思っておらず、実装を通じて改めてサーバーサイドのセキュリティ意識の大切さを学びました。
同じところで詰まっている方の参考になれば嬉しいです!