PHPでフォームのPOSTを受け取るには、$_POST['name'] ?? '' か filter_input(INPUT_POST, 'name') で値を取り出し、画面に出すときに htmlspecialchars() でエスケープします。 「受け取る」「確かめる(入力チェック)」「出力時にエスケープする」の3つを分けて考えると、フォームの処理は見通しよく書けます。

この記事では、入力→確認→完了まで動くフォーム一式(4ファイル)をPHP 8.4.21で動かし、その応答をそのまま載せています。 あわせて、未定義キーで出る Undefined array key のWarningを再現して直す手順、htmlspecialchars の引数の意味と h() 関数、チェックボックス配列の受け取り方、FILTER_VALIDATE_ で始まる検証フィルタの一覧と実行結果、FILTER_SANITIZE_STRING 非推奨への対応、CSRFトークンの最小実装までを1ページにまとめました。

フォーム受け取りの早見表

やりたいこと 書き方 補足
POSTの値を受け取る $name = $_POST['name'] ?? ''; キーが無くてもWarningが出ない
POSTの値を受け取る(関数で) $name = filter_input(INPUT_POST, 'name'); 無ければnull、配列が来たらfalse
GETの値を受け取る $q = $_GET['q'] ?? ''; / filter_input(INPUT_GET, 'q') URLの ?q=... の部分
整数として受け取る filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT) 整数でなければfalse
メール形式を確かめる filter_var($email, FILTER_VALIDATE_EMAIL) 形式に合わなければfalse
チェックボックス(複数)を受け取る filter_input(INPUT_POST, 'tags', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY) HTML側の名前は tags[]
画面に出す htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') h() 関数にまとめる
改行を保って出す nl2br(h($message)) 先に h()、あとで nl2br()
POSTで来たか確かめる $_SERVER['REQUEST_METHOD'] === 'POST' 直接開かれたときの分岐に使う
CSRFトークンを作る bin2hex(random_bytes(32)) セッションに保存し hash_equals() で比較
送信後の再読み込み対策 header('Location: complete.php', true, 303); exit; POSTのあとGETへ移す

処理の順番は次の4段階です。エスケープは「受け取るとき」ではなく「出力するとき」に行うのがポイントです。

段階 すること 使うもの
1. 受け取る キーの有無と型(文字列か配列か)を確かめて取り出す ??、filter_input
2. 整える 前後の空白を取る trim
3. 確かめる 必須・長さ・形式・選択肢に含まれるか filter_var、mb_strlen、array_key_exists
4. 出力する HTMLとして意味を持つ文字を置き換える htmlspecialchars(h())

検証に使った環境

項目 内容
PHP 8.4.21(ビルトインWebサーバー php -S 127.0.0.1:8620)
リクエストの送信 curl(Cookieを保存してセッションを引き継ぎ)
未実行の範囲 PHP 8.3での実行、ブラウザでの画面操作、Apache・nginx上での実行

タイトルの対象はPHP 8系ですが、今回実際に動かしたのはPHP 8.4.21のみです。記事中の出力はすべてこの環境のもので、ファイルのパスは /home/taro/app/ に置き換えて表示しています(表示は環境に合わせて置き換えています)。

$_POSTと$_GETの基本:フォームの値が届くしくみ

フォームの method が post なら $_POST、get(またはURLの ? 以降)なら $_GET に、入力欄の name 属性をキーとした連想配列として値が入ります。

比べる点 POST GET
値の場所 リクエストの本文 URLの ?key=value
PHPでの受け取り $_POST、INPUT_POST $_GET、INPUT_GET
向いている用途 登録・更新・削除・お問い合わせの送信 検索・絞り込み・ページ番号
URLの共有・ブックマーク 値は含まれない 値ごと共有できる
再読み込み 再送信の確認が出る そのまま再表示

$_REQUEST はGET・POST・Cookieをまとめた配列で、どこから来た値かが区別できなくなるため、送信方法に合わせて $_POST か $_GET を使い分ける書き方をおすすめします。

GETの受け取りも考え方は同じです。ページ番号を整数で、検索語を文字列で受け取る例を動かしました。

<?php
// get.php : URLの ?page=2&q=php を受け取る
header('Content-Type: text/plain; charset=UTF-8');
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, ['options' => ['default' => 1, 'min_range' => 1]]);
$q    = trim((string) filter_input(INPUT_GET, 'q'));
var_dump($page, $q);

実行結果(順に get.php?page=2&q=php、get.php?page=abc、get.php を開いたとき):

int(2)
string(3) "php"

int(1)
string(0) ""

int(1)
string(0) ""

整数でない値やキーなしのときは、default に指定した1になっています。

Undefined array keyのWarningを再現して直す

再現:何も送らずに開くとWarningが出る

入門記事でよく見かける $name = $_POST['name']; は、フォームから送信されたときは動きますが、そのページを直接開いたとき(POSTされていないとき)にWarningが出ます。

<?php
// warn.php : 何も送らずに開くと Warning が出る
$name = $_POST['name'];
echo 'こんにちは、' . $name . 'さん';

実行結果(display_errors=1 で curl http://127.0.0.1:8621/warn.php):

<br />
<b>Warning</b>:  Undefined array key "name" in <b>/home/taro/app/warn.php</b> on line <b>3</b><br />
こんにちは、さん

display_errors が無効の環境では画面には出ず、同じ内容がログに記録されました。

PHP 7まではNoticeだったものが、PHP 8.0でWarningに引き上げられています(PHPマニュアルの8.0移行ガイドの記載。PHP 7での実行は未実行です)。チェックボックスやラジオボタンは、何も選ばれていないとキー自体が送られないため、送信されたときでも同じWarningが起きます。

直し方1:??(null合体演算子)で既定値を付ける

<?php
$name = $_POST['name'] ?? '';

?? は左側が未定義またはnullのときだけ右側を返すので、キーが無くてもWarningは出ません。

直し方2:filter_inputで受け取る

<?php
$name = filter_input(INPUT_POST, 'name');

2つの直し方を並べて、送信なし・送信ありの両方で確かめました。

<?php
// fixed.php : ?? と filter_input で直した版
$name1 = $_POST['name'] ?? '';
$name2 = filter_input(INPUT_POST, 'name');
var_dump($name1, $name2);

実行結果(上がGETで開いたとき、下が name=sato をPOSTしたとき):

string(0) ""
NULL

string(4) "sato"
string(4) "sato"

どちらもWarningは出ていません。違いは、値が無いときに ?? は自分で決めた既定値、filter_input はnullを返す点です。

??とfilter_inputの違い

比べる点 $_POST['x'] ?? '' filter_input(INPUT_POST, 'x')
キーが無いとき 指定した既定値 null
配列が送られてきたとき(x[]=a) 配列がそのまま入る false(配列を受け付けない)
形式の確認を同時に行う 別に書く 第3引数にフィルタを指定できる
$_POST をプログラムで書き換えた値 反映される 反映されない(届いた時点の値を見る)
コマンドラインでの実行 $_POST に入れれば動く nullになる

最後の2行も実行して確かめた結果です(CLIで $_POST に代入してから filter_input を呼ぶと NULL)。

文字列のつもりの欄に配列が届くとき

HTMLを書き換えたり、curlで name[]=a と送ったりすると、文字列を想定した欄に配列が届きます。?? だけで受けて h() に渡すと、型が合わずに止まります。

<?php
header('Content-Type: text/plain; charset=UTF-8');
function h(?string $s): string { return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }

try { echo h($_POST['name'] ?? ''); }
catch (TypeError $e) { echo get_class($e), ': ', $e->getMessage(), "\n"; }

$name = filter_input(INPUT_POST, 'name');
var_dump($name);

実行結果(curl -d 'name[]=a' で送信):

TypeError: h(): Argument #1 ($s) must be of type ?string, array given, called in /home/taro/app/arr.php on line 5
bool(false)

filter_input は配列を受け付けずfalseを返すので、(string) filter_input(...) と書けば空文字として扱えます。外から届く値は「文字列とは限らない」前提で受け取ると、想定外の入力でも画面が止まりません。この記事の完成フォームでは filter_input を使っています。

filter_inputの戻り値を実行結果で確かめる

filter_input は「キーが無い」「検証に通らない」「通った」の3通りを戻り値で区別できます。

<?php
header('Content-Type: text/plain; charset=UTF-8');
echo "name      : "; var_dump(filter_input(INPUT_POST, 'name'));
echo "age(INT)  : "; var_dump(filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT));
echo "age(NULL) : "; var_dump(filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE));
echo "email     : "; var_dump(filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL));
echo "agree     : "; var_dump(filter_input(INPUT_POST, 'agree', FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE));

実行結果1(name=sato、age=20、email=sato@example.com、agree=on を送信):

name      : string(4) "sato"
age(INT)  : int(20)
age(NULL) : int(20)
email     : string(16) "sato@example.com"
agree     : bool(true)

実行結果2(age=abc、email=sato@、agree=maybe を送信。nameは送らない):

name      : NULL
age(INT)  : bool(false)
age(NULL) : NULL
email     : bool(false)
agree     : NULL
状況 通常 FILTER_NULL_ON_FAILURE を付けたとき
キーが無い null false
検証に通らない false null
検証に通った 変換後の値(FILTER_VALIDATE_INT ならint型) 同じ

FILTER_VALIDATE_INT を通った値は 文字列ではなくint型 になっている点も便利です。判定は === false や === null のように厳密な比較で書きます(0や空文字を誤って「通らなかった」と扱わないため)。

FILTER_VALIDATEで始まる検証フィルタ一覧(実行結果つき)

PHPマニュアルの「フィルタ定数」のページ(今回開いて確認)に載っている検証フィルタを、filter_var() で実際に動かしました。filter_var は変数を、filter_input は届いた入力を対象にする違いだけで、フィルタの指定方法は共通です。

検証フィルタの一覧

定数 確かめる内容 主なオプション・フラグ
FILTER_VALIDATE_INT 整数。通るとint型で返る min_range、max_range、default、FILTER_FLAG_ALLOW_HEX
FILTER_VALIDATE_FLOAT 小数。通るとfloat型で返る decimal、min_range、max_range、FILTER_FLAG_ALLOW_THOUSAND
FILTER_VALIDATE_BOOL "1" "true" "on" "yes" ならtrue FILTER_NULL_ON_FAILURE と組み合わせると判別しやすい
FILTER_VALIDATE_EMAIL メールアドレスの形式 FILTER_FLAG_EMAIL_UNICODE
FILTER_VALIDATE_URL URLの形式 FILTER_FLAG_PATH_REQUIRED、FILTER_FLAG_QUERY_REQUIRED
FILTER_VALIDATE_DOMAIN ドメイン名 FILTER_FLAG_HOSTNAME
FILTER_VALIDATE_IP IPアドレス FILTER_FLAG_IPV4、FILTER_FLAG_IPV6、FILTER_FLAG_NO_PRIV_RANGE など
FILTER_VALIDATE_MAC MACアドレス なし
FILTER_VALIDATE_REGEXP 正規表現に一致するか regexp(必須)

実行結果

オプションは ['options' => ['min_range' => 1, 'max_range' => 10]] の形で、フラグは第3引数(またはこの配列の flags)に渡します。24パターンを実行した結果を表にまとめます(PHP 8.4.21。結果の列は var_export の表示)。

フィルタ 入力 結果
FILTER_VALIDATE_INT "42" 42
FILTER_VALIDATE_INT "42abc" false
FILTER_VALIDATE_INT " 42 "(前後に空白) 42
FILTER_VALIDATE_INT ""(空文字) false
FILTER_VALIDATE_INT "0" 0
FILTER_VALIDATE_INT(1〜10) "11" false
FILTER_VALIDATE_INT(default=1) "abc" 1
FILTER_VALIDATE_FLOAT "1,234.5" false
FILTER_VALIDATE_FLOAT + FILTER_FLAG_ALLOW_THOUSAND "1,234.5" 1234.5
FILTER_VALIDATE_BOOL "on" true
FILTER_VALIDATE_BOOL "maybe" false
FILTER_VALIDATE_BOOL + FILTER_NULL_ON_FAILURE "maybe" NULL
FILTER_VALIDATE_EMAIL "sato@example.com" 'sato@example.com'
FILTER_VALIDATE_EMAIL "sato@" false
FILTER_VALIDATE_EMAIL "佐藤@example.com" false
FILTER_VALIDATE_EMAIL + FILTER_FLAG_EMAIL_UNICODE "佐藤@example.com" '佐藤@example.com'
FILTER_VALIDATE_URL "https://example.com/a?b=1" 'https://example.com/a?b=1'
FILTER_VALIDATE_URL "example.com" false
FILTER_VALIDATE_URL "javascript:alert(1)" false
FILTER_VALIDATE_DOMAIN + FILTER_FLAG_HOSTNAME "exa_mple.com" false
FILTER_VALIDATE_IP "192.168.0.1" '192.168.0.1'
FILTER_VALIDATE_IP + FILTER_FLAG_IPV6 "192.168.0.1" false
FILTER_VALIDATE_MAC "00:1A:2B:3C:4D:5E" '00:1A:2B:3C:4D:5E'
FILTER_VALIDATE_REGEXP(/^\d{3}-\d{4}$/) "123-4567" '123-4567'

読み取れるポイントは次のとおりです。

  • FILTER_VALIDATE_INT は前後の空白を許し、"0" を0として通す。 if (!$age) と書くと0が通らなかった扱いになるので、=== false で比べる。
  • FILTER_VALIDATE_BOOL は「falseと判定された」と「判定できなかった」がどちらもfalse。 区別したいときは FILTER_NULL_ON_FAILURE を付ける。
  • FILTER_VALIDATE_URL はスキーム(https:// など)が無いと通らない。 "javascript:alert(1)" は今回falseでしたが、リンク先として出力するURLは、スキームが http か https であることも合わせて確かめておくと確実です。
  • 空文字はどの検証フィルタでも通らない。 任意項目は「入力があるときだけ検証する」と書く(完成フォームの年齢欄を参照)。

htmlspecialcharsとh()関数

なぜエスケープが必要か

フォームから届いた文字列をそのままHTMLに出すと、入力に含まれるタグがタグとして解釈されます。htmlspecialchars() は、HTMLで特別な意味を持つ5つの文字を、表示用の文字参照に置き換えます。

文字 置き換え後
& &amp;
< &lt;
> &gt;
" &quot;
' &#039;(ENT_QUOTES のとき)
<?php
header('Content-Type: text/plain; charset=UTF-8');
function h(?string $s): string { return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }

$name = $_POST['name'] ?? '';
echo "そのまま : ", $name, "\n";
echo "h() 経由 : ", h($name), "\n";

実行結果(1回目はscriptタグ、2回目は引用符を含む文字列を送信):

そのまま : <script>alert(1)</script>
h() 経由 : &lt;script&gt;alert(1)&lt;/script&gt;

そのまま : " onmouseover='x' &
h() 経由 : &quot; onmouseover=&#039;x&#039; &amp;

h() を通したほうは、ブラウザで開いても「文字」として表示されるだけになります。

第2引数(フラグ)と第3引数(文字コード)

<?php
$s = "<a href='x'>\"T&C\"</a>";
echo htmlspecialchars($s), "\n";                          // 引数を省略
echo htmlspecialchars($s, ENT_QUOTES, 'UTF-8'), "\n";
echo htmlspecialchars($s, ENT_COMPAT, 'UTF-8'), "\n";
echo htmlspecialchars($s, ENT_NOQUOTES, 'UTF-8'), "\n";

実行結果(PHP 8.4.21):

&lt;a href=&#039;x&#039;&gt;&quot;T&amp;C&quot;&lt;/a&gt;
&lt;a href=&#039;x&#039;&gt;&quot;T&amp;C&quot;&lt;/a&gt;
&lt;a href='x'&gt;&quot;T&amp;C&quot;&lt;/a&gt;
&lt;a href='x'&gt;"T&amp;C"&lt;/a&gt;
引数 意味 おすすめの指定
第2引数 ENT_QUOTES ダブルクォートとシングルクォートの両方を置き換える 属性値を '...' で囲むHTMLでも安全に出せるので指定する
第2引数 ENT_COMPAT ダブルクォートだけ置き換える ―
第2引数 ENT_NOQUOTES クォートを置き換えない ―
第2引数 ENT_SUBSTITUTE 文字コードとして正しくないバイト列を「�」に置き換える ENT_QUOTES と | でつないで指定する
第3引数 'UTF-8' 入力の文字コード 明示する
第4引数 double_encode falseにすると、すでに文字参照になっている部分を二重に変換しない 通常は省略(true)

PHP 8.4.21では、引数を省略した1行目でもシングルクォートが置き換わっていました。PHP 8.1以降は省略時の既定が ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 になったことが一般に知られていますが(8.0以前での実行は未実行)、どのバージョンでも同じ結果になるよう、ENT_QUOTES と 'UTF-8' は明示しておくのがおすすめです。

ENT_SUBSTITUTE の有無による違いも確かめました。UTF-8として正しくないバイトを含む文字列を渡した結果です。

<?php
var_dump(htmlspecialchars("abc\xE3def", ENT_QUOTES, 'UTF-8'));
var_dump(htmlspecialchars("abc\xE3def", ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
string(0) ""
string(9) "abc�def"

ENT_SUBSTITUTE が無いと、文字列全体が空文字になります。 入力の一部が欠けて見える原因になるので、付けておくと安心です。

h()関数にまとめる

毎回3つの引数を書くのは手間なので、短い関数にまとめます。

<?php
function h(?string $s): string
{
    return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

引数を ?string にして $s ?? '' としているのは、nullを直接渡すとPHP 8.1以降で次の表示が出るためです(8.4.21で実行)。

Deprecated: htmlspecialchars(): Passing null to parameter #1 ($string) of type string is deprecated in /home/taro/app/null.php on line 3

エスケープする場所

出力する場所 書き方
タグの中身 <?= h($name) ?>
属性値(value など) value="<?= h($name) ?>"(必ず引用符で囲む)
改行を保って表示 <?= nl2br(h($message)) ?>
URLのパラメータ urlencode($q) してから、属性値として h()
JavaScriptに渡す値 json_encode($value, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)

保存するデータはエスケープせず元のまま持ち、出力する直前に h() を通すと、二重に変換されることがありません。すでに変換済みの文字列をもう一度通すと、&lt;b&gt; が &amp;lt;b&amp;gt; になることを実行して確かめました。

チェックボックス配列の受け取り方

複数選択のチェックボックスは、name の末尾に [] を付けると配列として届きます。

<label><input type="checkbox" name="tags[]" value="php"> PHP</label>
<label><input type="checkbox" name="tags[]" value="js"> JavaScript</label>
<label><input type="checkbox" name="tags[]" value="sql"> SQL</label>
<?php
header('Content-Type: text/plain; charset=UTF-8');
echo "tags(配列): ";     var_dump(filter_input(INPUT_POST, 'tags', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY));
echo "tags(指定なし): "; var_dump(filter_input(INPUT_POST, 'tags'));
echo "\$_POST['tags'] ?? []: "; var_dump($_POST['tags'] ?? []);

実行結果1(PHPとSQLにチェックを入れた想定で tags[]=php&tags[]=sql を送信):

tags(配列): array(2) {
  [0]=>
  string(3) "php"
  [1]=>
  string(3) "sql"
}
tags(指定なし): bool(false)
$_POST['tags'] ?? []: array(2) {
  [0]=>
  string(3) "php"
  [1]=>
  string(3) "sql"
}

実行結果2(何もチェックしないで送信):

tags(配列): NULL
tags(指定なし): NULL
$_POST['tags'] ?? []: array(0) {
}
ポイント 内容
何も選ばないとキーが届かない ?? [] や ?: [] で空配列にしておく
filter_input は FILTER_REQUIRE_ARRAY が必要 付けないとfalseになる
届いた値は選択肢と照合する array_intersect($tags, array_keys(選択肢)) で、用意した値だけ残す
チェック1つだけのとき name="agree" とし、isset($_POST['agree']) か FILTER_VALIDATE_BOOL で判定

ラジオボタンやセレクトボックスも同じで、「画面に用意した選択肢のどれかであること」を array_key_exists() や in_array($v, $list, true) で確かめます。 画面で選べない値も、リクエストを直接組み立てれば送れるためです。

FILTER_SANITIZE_STRING非推奨への対応

以前の解説では、受け取り時に FILTER_SANITIZE_STRING でタグを取り除く書き方が紹介されていました。この定数は PHP 8.1.0から非推奨 で、PHPマニュアルには代わりに htmlspecialchars() を使うよう書かれています(今回ページを開いて確認)。PHP 8.4.21で使うと次の表示が出ます。

<?php
error_reporting(E_ALL); ini_set('display_errors', '1');
$in = '<b>太郎</b> & "花子"';
var_dump(filter_var($in, FILTER_SANITIZE_STRING));
var_dump(filter_var($in, FILTER_SANITIZE_FULL_SPECIAL_CHARS));
var_dump(filter_var($in, FILTER_SANITIZE_SPECIAL_CHARS));
var_dump(htmlspecialchars($in, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
var_dump(strip_tags($in));
var_dump(filter_var('<b>太郎</b>', FILTER_DEFAULT));

実行結果:

Deprecated: Constant FILTER_SANITIZE_STRING is deprecated in /home/taro/app/sanitize.php on line 4
string(25) "太郎 & &#34;花子&#34;"
string(50) "&lt;b&gt;太郎&lt;/b&gt; &amp; &quot;花子&quot;"
string(52) "&#60;b&#62;太郎&#60;/b&#62; &#38; &#34;花子&#34;"
string(50) "&lt;b&gt;太郎&lt;/b&gt; &amp; &quot;花子&quot;"
string(17) "太郎 & "花子""
string(13) "<b>太郎</b>"
以前の書き方 していたこと これからの書き方
FILTER_SANITIZE_STRING タグを除去し、引用符を文字参照に変換 受け取りはフィルタなし(FILTER_DEFAULT)、出力時に h()
同上(タグの除去が目的) タグを取り除く strip_tags()(引用符は変換されない)
同上(HTML用の変換が目的) & や引用符の変換 htmlspecialchars()、または FILTER_SANITIZE_FULL_SPECIAL_CHARS(今回の入力では h() と同じ結果)

FILTER_DEFAULT は何も加工せずに返すフィルタです(最後の行でタグがそのまま残っています)。受け取るときは加工せず、出力するときに h() を通すという方針にそろえると、この非推奨の影響を受けません。

CSRFトークンの最小実装

CSRF(クロスサイトリクエストフォージェリ)は、利用者が意図していない送信を別のサイト経由で行わせる手口です。防ぐ基本は、フォームを表示したときに発行した合言葉(トークン)が、送信時に一緒に届いているかを確かめることです。必要なのは「作る・埋め込む・確かめる」の3か所です。

コードは次の章の lib.php にある csrf_token()(作る)と csrf_check()(確かめる)、そして各フォームの hidden 項目(埋め込む)です。

部品 役割
random_bytes(32) 推測されにくい乱数を作る(bin2hex で64文字の16進文字列に)
$_SESSION 利用者ごとにトークンを覚えておく
hash_equals() 比較にかかる時間から中身を推測されにくい比較関数
??= トークンが無いときだけ作る

トークンなし・誤ったトークンで送信したときの応答は、次の章の動作確認に載せています(どちらも400で止まりました)。セッションやCSRFの詳しいしくみは範囲が広いので、ここでは最小の形に絞っています。

入力→確認→完了まで動くフォーム一式

4つのファイルを同じフォルダに置き、php -S 127.0.0.1:8620 を実行すると動きます。

ファイル 役割
lib.php 共通関数(h()、CSRF、受け取りと入力チェック)
index.php 入力画面。エラーと前回の入力を表示する
confirm.php POSTを受け取り、入力チェック。通れば確認画面、通らなければ入力画面へ戻す
complete.php 送信の確定(保存・メール送信の場所)と完了画面
flowchart LR
    A[index.php<br>入力] -- POST + トークン --> B[confirm.php<br>入力チェック]
    B -- エラーあり 302 --> A
    B -- OK --> C[確認画面を表示]
    C -- POST + トークン --> D[complete.php<br>保存・送信]
    D -- 303 --> E[complete.php<br>完了画面 GET]

lib.php(共通関数)

<?php
// lib.php : 3つの画面で共通に使う関数
declare(strict_types=1);

session_start();

/** HTMLに出力する前に必ず通すエスケープ関数 */
function h(?string $s): string
{
    return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/** CSRFトークンを取得する(無ければ作る) */
function csrf_token(): string
{
    return $_SESSION['csrf_token'] ??= bin2hex(random_bytes(32));
}

/** 送られてきたトークンを確かめる。合わなければ400で終了 */
function csrf_check(): void
{
    $sent = filter_input(INPUT_POST, 'csrf_token');
    if (!is_string($sent) || !hash_equals($_SESSION['csrf_token'] ?? '', $sent)) {
        http_response_code(400);
        exit('不正なリクエストです。入力画面からやり直してください。');
    }
}

const CATEGORIES = ['question' => 'ご質問', 'request' => 'ご要望', 'other' => 'その他'];
const INTERESTS  = ['php' => 'PHP', 'js' => 'JavaScript', 'sql' => 'SQL'];

/** POSTを受け取り、[整えた値, エラーの配列] を返す */
function read_and_validate(): array
{
    $data = [
        'name'      => trim((string) filter_input(INPUT_POST, 'name')),
        'email'     => trim((string) filter_input(INPUT_POST, 'email')),
        'age'       => trim((string) filter_input(INPUT_POST, 'age')),
        'category'  => (string) filter_input(INPUT_POST, 'category'),
        'interests' => filter_input(INPUT_POST, 'interests', FILTER_DEFAULT, FILTER_REQUIRE_ARRAY) ?: [],
        'message'   => trim((string) filter_input(INPUT_POST, 'message')),
    ];
    $errors = [];

    if ($data['name'] === '') {
        $errors['name'] = 'お名前を入力してください。';
    } elseif (mb_strlen($data['name']) > 50) {
        $errors['name'] = 'お名前は50文字以内で入力してください。';
    }

    if ($data['email'] === '') {
        $errors['email'] = 'メールアドレスを入力してください。';
    } elseif (filter_var($data['email'], FILTER_VALIDATE_EMAIL) === false) {
        $errors['email'] = 'メールアドレスの形式を確認してください。';
    }

    if ($data['age'] !== '') {   // 任意項目:入力があるときだけ確かめる
        $age = filter_var($data['age'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 120]]);
        if ($age === false) {
            $errors['age'] = '年齢は0〜120の整数で入力してください。';
        }
    }

    if (!array_key_exists($data['category'], CATEGORIES)) {
        $errors['category'] = '種別を選んでください。';
    }

    // チェックボックスは、用意した選択肢に含まれる値だけを残す
    $data['interests'] = array_values(array_intersect((array) $data['interests'], array_keys(INTERESTS)));

    if ($data['message'] === '') {
        $errors['message'] = 'お問い合わせ内容を入力してください。';
    } elseif (mb_strlen($data['message']) > 1000) {
        $errors['message'] = 'お問い合わせ内容は1000文字以内で入力してください。';
    }

    return [$data, $errors];
}

index.php(入力画面)

<?php
// index.php : 入力画面
require __DIR__ . '/lib.php';

$old    = $_SESSION['form']   ?? [];
$errors = $_SESSION['errors'] ?? [];
unset($_SESSION['errors']);
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>お問い合わせ(入力)</title>
</head>
<body>
<h1>お問い合わせ</h1>
<form action="confirm.php" method="post">
  <input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">

  <p><label>お名前(必須)<br>
    <input type="text" name="name" value="<?= h($old['name'] ?? '') ?>"></label>
    <?php if (isset($errors['name'])): ?><span class="error"><?= h($errors['name']) ?></span><?php endif; ?></p>

  <p><label>メールアドレス(必須)<br>
    <input type="email" name="email" value="<?= h($old['email'] ?? '') ?>"></label>
    <?php if (isset($errors['email'])): ?><span class="error"><?= h($errors['email']) ?></span><?php endif; ?></p>

  <p><label>年齢(任意)<br>
    <input type="text" name="age" value="<?= h($old['age'] ?? '') ?>"></label>
    <?php if (isset($errors['age'])): ?><span class="error"><?= h($errors['age']) ?></span><?php endif; ?></p>

  <p><label>種別(必須)<br>
    <select name="category">
      <option value="">選択してください</option>
      <?php foreach (CATEGORIES as $value => $label): ?>
      <option value="<?= h($value) ?>"<?= ($old['category'] ?? '') === $value ? ' selected' : '' ?>><?= h($label) ?></option>
      <?php endforeach; ?>
    </select></label>
    <?php if (isset($errors['category'])): ?><span class="error"><?= h($errors['category']) ?></span><?php endif; ?></p>

  <p>興味のある分野(複数選択可)<br>
    <?php foreach (INTERESTS as $value => $label): ?>
    <label><input type="checkbox" name="interests[]" value="<?= h($value) ?>"<?= in_array($value, $old['interests'] ?? [], true) ? ' checked' : '' ?>> <?= h($label) ?></label>
    <?php endforeach; ?></p>

  <p><label>お問い合わせ内容(必須)<br>
    <textarea name="message" rows="5" cols="40"><?= h($old['message'] ?? '') ?></textarea></label>
    <?php if (isset($errors['message'])): ?><span class="error"><?= h($errors['message']) ?></span><?php endif; ?></p>

  <p><button type="submit">確認画面へ</button></p>
</form>
</body>
</html>

confirm.php(確認画面)

<?php
// confirm.php : 確認画面
require __DIR__ . '/lib.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {   // 直接開かれたら入力画面へ
    header('Location: index.php');
    exit;
}
csrf_check();

[$data, $errors] = read_and_validate();
$_SESSION['form'] = $data;

if ($errors !== []) {                           // エラーがあれば入力画面に戻す
    $_SESSION['errors'] = $errors;
    header('Location: index.php');
    exit;
}
$_SESSION['confirmed'] = true;

$interestLabels = array_map(fn (string $k): string => INTERESTS[$k], $data['interests']);
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>お問い合わせ(確認)</title>
</head>
<body>
<h1>入力内容の確認</h1>
<dl>
  <dt>お名前</dt><dd><?= h($data['name']) ?></dd>
  <dt>メールアドレス</dt><dd><?= h($data['email']) ?></dd>
  <dt>年齢</dt><dd><?= $data['age'] === '' ? '(未入力)' : h($data['age']) ?></dd>
  <dt>種別</dt><dd><?= h(CATEGORIES[$data['category']]) ?></dd>
  <dt>興味のある分野</dt><dd><?= $interestLabels === [] ? '(選択なし)' : h(implode('、', $interestLabels)) ?></dd>
  <dt>お問い合わせ内容</dt><dd><?= nl2br(h($data['message'])) ?></dd>
</dl>
<form action="complete.php" method="post">
  <input type="hidden" name="csrf_token" value="<?= h(csrf_token()) ?>">
  <button type="submit">送信する</button>
</form>
<p><a href="index.php">入力画面に戻る</a></p>
</body>
</html>

complete.php(完了画面)

<?php
// complete.php : 完了画面
require __DIR__ . '/lib.php';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    csrf_check();
    if (empty($_SESSION['confirmed']) || empty($_SESSION['form'])) {
        header('Location: index.php');
        exit;
    }
    $data = $_SESSION['form'];

    // ここで保存やメール送信を行う(この例ではログに1行書くだけ)
    error_log('お問い合わせを受け付けました: ' . json_encode($data, JSON_UNESCAPED_UNICODE));

    unset($_SESSION['form'], $_SESSION['confirmed'], $_SESSION['csrf_token']);
    $_SESSION['done_name'] = $data['name'];
    header('Location: complete.php', true, 303);   // 再読み込みで二重送信にならないようGETへ
    exit;
}

$name = $_SESSION['done_name'] ?? null;
unset($_SESSION['done_name']);
if ($name === null) {
    header('Location: index.php');
    exit;
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>お問い合わせ(完了)</title>
</head>
<body>
<h1>送信が完了しました</h1>
<p><?= h($name) ?> 様、お問い合わせありがとうございました。</p>
<p><a href="index.php">入力画面に戻る</a></p>
</body>
</html>

動作確認の結果(PHP 8.4.21 + curl)

php -S 127.0.0.1:8620 で起動し、curlでCookieを引き継ぎながら順に送信しました。

番号 操作 応答
1 index.php をGET 200。64文字のトークンがhiddenに入っている
2 トークンなしで confirm.php にPOST 400「不正なリクエストです。入力画面からやり直してください。」
3 誤ったトークン(abc)でPOST 400(同じメッセージ)
4 空の名前・sato@・年齢200・種別 zzz・空の本文をPOST 302で index.php へ。エラー5件を表示
5 正しい内容をPOST 200。確認画面を表示
6 確認画面から complete.php にPOST 303で complete.php へ。完了画面を表示
7 完了画面を再読み込み 302で index.php へ(二重送信にならない)
8 使用済みのトークンで complete.php に再POST 400
9 confirm.php を直接GET 302で index.php へ

4番のあとに入力画面へ表示されたエラー:

お名前を入力してください。
メールアドレスの形式を確認してください。
年齢は0〜120の整数で入力してください。
種別を選んでください。
お問い合わせ内容を入力してください。

5番では、名前にタグ、本文に改行・引用符・& を入れ、チェックボックスには用意していない値 hack も混ぜて送りました。確認画面のHTMLは次のとおりです。

<dl>
  <dt>お名前</dt><dd>佐藤 &lt;b&gt;太郎&lt;/b&gt;</dd>
  <dt>メールアドレス</dt><dd>sato@example.com</dd>
  <dt>年齢</dt><dd>30</dd>
  <dt>種別</dt><dd>ご質問</dd>
  <dt>興味のある分野</dt><dd>PHP、SQL</dd>
  <dt>お問い合わせ内容</dt><dd>1行目です。<br />
2行目 &quot;引用&quot; &amp; 記号</dd>
</dl>
  • 名前のタグは &lt;b&gt; に置き換わり、文字として表示される。
  • 選択肢にない hack は取り除かれ、「PHP、SQL」だけが残っている。
  • 本文の改行は nl2br() で改行タグになり、引用符と & はエスケープされている。

6番の送信時にサーバーのログへ出た行です。保存されるデータはエスケープ前の元の文字列であることがわかります。

お問い合わせを受け付けました: {"name":"佐藤 <b>太郎<\/b>","email":"sato@example.com","age":"30","category":"question","interests":["php","sql"],"message":"1行目です。\n2行目 \"引用\" & 記号"}

ブラウザでの画面操作は今回行っていません(未実行)。curlで確認したのは、各画面のHTMLとステータスコード、リダイレクト先です。

このフォームで押さえているポイント

ポイント 該当する箇所
未定義キーでWarningを出さない filter_input と ?? で受け取り
配列が届いても止まらない 文字列の欄は (string) filter_input(...)
任意項目は入力があるときだけ検証 年齢欄の if ($data['age'] !== '')
選択肢は一覧と照合 array_key_exists、array_intersect
出力はすべて h() 経由 入力画面の value、確認画面、完了画面
エラー時に入力内容を残す $_SESSION['form'] を入力画面で再表示
CSRFトークンの確認 confirm.php と complete.php の先頭で csrf_check()
再読み込みでの二重送信を避ける 送信後に303でGETへ移す
直接アクセスへの対応 REQUEST_METHOD の確認とリダイレクト

公開するサイトで使うときは、HTTPSでの提供、セッションCookieの設定(secure、httponly、samesite)、メール送信やデータベース保存の処理、送信回数の制限などを用途に合わせて加えてください。

よくある質問

Q. $_POST と filter_input はどちらを使えばよいですか? A. どちらでも受け取れます。$_POST['x'] ?? '' は短く書け、テストで値を差し替えやすいのが利点です。filter_input は配列が届いたときにfalseを返し、整数やメール形式の確認を同時に行えるのが利点です。文字列の欄に配列が届く場合まで考えるなら、filter_input か is_string() での確認を入れておくと安心です。

Q. エスケープは受け取ったときにまとめて行ってもよいですか? A. 出力する直前に行うのがおすすめです。受け取り時に変換すると、データベースやメール本文に変換後の文字が入り、あとでHTMLに出すときに二重に変換されることがあります。元のまま保持し、HTMLに出すときは h()、SQLに渡すときはプリペアドステートメント、と出力先ごとに合った方法を使い分けます。

まとめ

  • 受け取りは $_POST['x'] ?? '' か filter_input(INPUT_POST, 'x')。 どちらも未定義キーのWarningが出ない。
  • filter_input はキーなしでnull、検証に通らないとfalse、配列が届いてもfalse。 判定は === で書く。
  • 形式の確認は FILTER_VALIDATE_INT・FILTER_VALIDATE_EMAIL などの検証フィルタ。 任意項目は入力があるときだけ検証する。
  • 出力時に htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')。 h() 関数にまとめ、保存するデータは元のまま持つ。
  • チェックボックスは name="tags[]" と FILTER_REQUIRE_ARRAY。 届いた値は選択肢の一覧と照合する。
  • FILTER_SANITIZE_STRING はPHP 8.1から非推奨。 受け取りでは加工せず、出力時のエスケープにそろえる。
  • CSRFトークンは random_bytes で作り、セッションに保存して hash_equals で比較。 送信後は303でGETへ移して二重送信を避ける。

参考・出典

  • PHPマニュアル「フィルタ定数」 https://www.php.net/manual/ja/filter.constants.php
  • PHPマニュアル「PHP 8.0 下位互換性のない変更点」 https://www.php.net/manual/ja/migration80.incompatible.php

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?