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

文法書で変数や条件分岐、連想配列を学んだ初学者が、Web制作の現場で最初に任されるタスクの代表格が「お問い合わせフォームの実装」です。

しかし、ユーザーから送信されるデータ($_POST)は、決して開発者の思い通りに送られてくるとは限りません。全角スペースだけを入力して送信されたり、メールアドレスに不正な文字列が紛れ込んだり、選択肢にない値が悪意を持って改ざん送信されたりします。

そこで今回の課題では、「お問い合わせフォームのPOSTデータを受け取り、全項目を漏れなく検証してエラーリストを返す関数」を作成します。

問題の入力データ(検証対象の配列)

HTMLフォームから送信されたと想定される連想配列 $input を引数として受け取ります。

$input = [
    'name'    => '山田 太郎',
    'email'   => 'yamada@example.com',
    'subject' => 'general', // 選択肢: general / support / business
    'message' => 'サービスの料金プランについて詳しく教えてください。',
    'agree'   => '1',       // 同意チェック: '1' のみ許可
];

満たすべき要件仕様

  1. 関数の基本設計: 関数名は validateContactForm(array $input): array とし、検証に失敗した項目名をキー、エラーメッセージを値とする連想配列 array<string, string> を返すこと(エラーがなければ空配列 [] を返却)。
  2. お名前(name)の検証:
    • 必須入力であること(前後の空白を除去し、半角・全角空白のみの入力は未入力とみなす)。
    • 文字数は50文字以内であること(日本語などのマルチバイト文字を正確にカウントすること)。
  3. メールアドレス(email)の検証:
    • 必須入力であること。
    • 最大255文字以内であること。
    • 標準的なメールアドレス形式(RFC準拠)を満たしていること。
  4. お問い合わせ種別(subject)の検証:
    • 必須選択であること。
    • あらかじめ定義したホワイトリスト(general: 一般, support: サポート, business: 提携)のいずれかと厳密に一致すること。改ざんされた値は弾くこと。
  5. お問い合わせ内容(message)の検証:
    • 必須入力であること(空白のみは不可)。
    • 10文字以上、1000文字以内であること。
  6. 利用規約同意(agree)の検証:
    • チェック必須であること(文字列の '1' と完全一致すること)。未送信(チェックなし)時はエラーとすること。
  7. 安全なHTMLエスケープ関数(h())の用意: エラーメッセージや入力値を画面に再表示する際にXSS(クロスサイトスクリプティング)を防止できるヘルパー関数を用意すること。

期待される実行結果

// ケース1: 正常な入力値の場合
$result1 = validateContactForm($validInput);
// 期待値: [] (エラーなし)

// ケース2: 不正な入力値(空欄・形式不正・未同意など)の場合
$result2 = validateContactForm($invalidInput);
/* 期待値:
Array
(
    [name] => お名前を入力してください。
    [email] => メールアドレスの形式が正しくありません。
    [subject] => 無効なお問い合わせ種別が選択されました。
    [message] => お問い合わせ内容は10文字以上で具体的に入力してください。
    [agree] => 個人情報の取り扱いに同意いただく必要があります。
)
*/

まずは一度ご自身でエディタを開き、要件を満たす関数コードを書いてみてください!

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

独学でPHPを勉強している方のコードをレビューすると、以下のような「一見動いているけれど現場では即却下される危険なコード」が非常によく見受けられます。

<?php
// ❌ 初心者がやりがちなNGコード例
function checkForm($data) {
    $errors = [];

    // NG①: empty() の罠(文字列 "0" が入力されたときに空と判定される)
    if (empty($data['name'])) {
        $errors['name'] = '名前を入力してください';
    } else if (strlen($data['name']) > 50) { // NG②: strlen で日本語の文字数を数えてしまう
        $errors['name'] = '名前は50文字以内で入力してください';
    }

    // NG③: 雑な正規表現や曖昧なメールチェック
    if (empty($data['email'])) {
        $errors['email'] = 'メールアドレスを入力してください';
    } else if (!preg_match('/@/', $data['email'])) {
        $errors['email'] = 'メールアドレスが正しくありません';
    }

    // NG④: in_array の緩い比較($strict = true を省略)
    $allowed = ['general', 'support', 'business'];
    if (!in_array($data['subject'], $allowed)) {
        $errors['subject'] = '種別を選択してください';
    }

    // NG⑤: キーの存在確認をせず直接アクセス(Undefined array key の警告)
    if (strlen($data['message']) < 10) {
        $errors['message'] = '10文字以上で入力してください';
    }

    if ($data['agree'] != '1') {
        $errors['agree'] = '規約に同意してください';
    }

    return $errors;
}

// NG⑥: 画面出力時に生でecho(XSSの脆弱性)
echo "<p>こんにちは、" . $_POST['name'] . "さん</p>";
?>

なぜこのコードは現場でNGなのか?

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

それでは、PHP 8.3/8.4基準のモダンで安全な模範解答コードです。ファイル名 validation_drill.php として保存し、ターミナルで php validation_drill.php を実行すればそのまま動作します。

<?php

declare(strict_types=1);

/**
 * XSS対策用のHTMLエスケープ関数
 */
function h(?string $str): string
{
    return htmlspecialchars($str ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

/**
 * 全角・半角スペースを取り除くマルチバイト対応トリム
 * (PHP 8.4未満でも安全に動作する互換実装)
 */
function mbTrim(string $string): string
{
    if (function_exists('mb_trim')) {
        return mb_trim($string);
    }
    // 前後の全角・半角空白、改行等を正規表現で除去
    return preg_replace('/\A[\p{Z}\s]+|[\p{Z}\s]+\z/u', '', $string) ?? '';
}

/**
 * お問い合わせフォームの入力値を検証する
 *
 * @param array<string, mixed> $input
 * @return array<string, string> 検証エラーの連想配列(キー: 項目名, 値: エラー文)
 */
function validateContactForm(array $input): array
{
    $errors = [];

    // 定義済みの問い合わせ種別(ホワイトリスト)
    $allowedSubjects = [
        'general'  => '一般的なお問い合わせ',
        'support'  => 'サポート・不具合報告',
        'business' => 'お仕事のご相談・お見積もり',
    ];

    // 1. お名前(name)の検証: 必須、最大50文字
    $rawName = isset($input['name']) && is_string($input['name']) ? $input['name'] : '';
    $name = mbTrim($rawName);

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

    // 2. メールアドレス(email)の検証: 必須、有効なRFC形式、最大255文字
    $rawEmail = isset($input['email']) && is_string($input['email']) ? $input['email'] : '';
    $email = trim($rawEmail);

    if ($email === '') {
        $errors['email'] = 'メールアドレスを入力してください。';
    } elseif (strlen($email) > 255) {
        $errors['email'] = 'メールアドレスが長すぎます(255文字以内)。';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errors['email'] = 'メールアドレスの形式が正しくありません。';
    }

    // 3. お問い合わせ種別(subject)の検証: 必須、ホワイトリスト一致
    $rawSubject = isset($input['subject']) && is_string($input['subject']) ? $input['subject'] : '';

    if ($rawSubject === '') {
        $errors['subject'] = 'お問い合わせ種別を選択してください。';
    } elseif (!array_key_exists($rawSubject, $allowedSubjects)) {
        $errors['subject'] = '無効なお問い合わせ種別が選択されました。';
    }

    // 4. お問い合わせ内容(message)の検証: 必須、10〜1000文字
    $rawMessage = isset($input['message']) && is_string($input['message']) ? $input['message'] : '';
    $message = mbTrim($rawMessage);

    if ($message === '') {
        $errors['message'] = 'お問い合わせ内容を入力してください。';
    } else {
        $messageLength = mb_strlen($message, 'UTF-8');
        if ($messageLength < 10) {
            $errors['message'] = 'お問い合わせ内容は10文字以上で具体的に入力してください。';
        } elseif ($messageLength > 1000) {
            $errors['message'] = 'お問い合わせ内容は1000文字以内で入力してください。';
        }
    }

    // 5. 利用規約同意(agree)の検証: 必須、'1' のみ許可
    $agree = isset($input['agree']) && is_string($input['agree']) ? $input['agree'] : '';
    if ($agree !== '1') {
        $errors['agree'] = '個人情報の取り扱いに同意いただく必要があります。';
    }

    return $errors;
}

// ==========================================
// 動作確認用テストケース
// ==========================================

echo "========================================\n";
echo "【テスト1】正常な入力値の検証\n";
echo "========================================\n";

$validInput = [
    'name'    => '山田 太郎',
    'email'   => 'yamada@example.com',
    'subject' => 'general',
    'message' => 'サービスの料金プランとお支払い方法について教えてください。',
    'agree'   => '1',
];

$errors1 = validateContactForm($validInput);
if (empty($errors1)) {
    echo "✓ バリデーション成功!エラーはありません。\n";
    echo "安全な画面表示プレビュー:\n";
    echo "・お名前: " . h($validInput['name']) . "\n";
    echo "・種別: " . h($validInput['subject']) . "\n";
} else {
    echo "✕ 想定外のエラーが発生しました:\n";
    print_r($errors1);
}

echo "\n========================================\n";
echo "【テスト2】不正な入力値の検証(エラー集約確認)\n";
echo "========================================\n";

$invalidInput = [
    'name'    => '   ',                  // 全角空白のみ
    'email'   => 'invalid-email-address',   // 不正なメアド
    'subject' => 'malicious_input',         // 選択肢改ざん
    'message' => '短すぎ',                  // 10文字未満
    // agree は未送信(キー欠落)
];

$errors2 = validateContactForm($invalidInput);
echo "検出されたエラー数: " . count($errors2) . " 件\n";
foreach ($errors2 as $field => $msg) {
    echo "・[{$field}]: {$msg}\n";
}
echo "========================================\n";

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

この模範解答は、現場のコードレビューでシニアエンジニアから「おっ、細かい仕様までしっかり考慮されているな!」と合格点をもらえる設計になっています。特に注目してほしいポイントを解説します。

ポイント①:未入力判定は empty() ではなく $val === ''

模範コードでは、トリム処理を行った文字列に対して if ($name === '') と厳密に空文字チェックを行っています。

「未入力であること」と「値が0であること」は全く別の概念です。実務では empty() に頼るのをやめ、型を保証した上で === '' で比較する習慣をつけましょう。これだけで予期せぬ判定バグの9割が防げます。

ポイント②:全角スペースも除去するトリム処理と PHP 8.4 の mb_trim()

PHP標準の trim() は半角スペースやタブ、改行を除去しますが、日本語の全角スペース(\u{3000})は除去してくれません。そのため、名前欄に全角スペースだけを入れて送信されるとバリデーションをすり抜けてしまいます。

模範解答では、PHP 8.4で標準導入された mb_trim() を利用しつつ、PHP 8.3以前の環境でも動作するように正規表現(\p{Z}: Unicode空白文字プロパティ)を用いた互換関数 mbTrim() を用意しています。

ポイント③:日本語の文字数チェックは必ず mb_strlen($str, 'UTF-8')

マルチバイト文字(日本語や絵文字)の文字数を正確に取得するため、mb_strlen() を使用しています。さらに、第2引数に明示的に 'UTF-8' を渡すのがプロの作法です。サーバーの php.ini 設定(内部文字エンコーディング)に左右されない堅牢なコードになります。

ポイント④:メールアドレスのバリデーションは filter_var() 一択

初心者は「正規表現でメールアドレスを検証しよう」と車輪の再発明をしがちですが、RFCに準拠したメールアドレスの正規表現は数十行に及ぶほど複雑怪奇です。自作の正規表現はほぼ100%バグを含みます。
PHPには実績ある標準機能 filter_var($email, FILTER_VALIDATE_EMAIL) が用意されています。これを使うのがWeb業界のデファクトスタンダードです。

ポイント⑤:改ざん防止の「ホワイトリスト検証」

セレクトボックス(subject)の値を検証する際、「空でないか」を調べるだけでは不十分です。ブラウザの開発者ツールを使えば、HTMLの <option value="..."> は誰でも簡単に改ざんできます。
サーバー側で許可されたキーを連想配列として保持し、array_key_exists($rawSubject, $allowedSubjects) で厳密に突合することで、不正なリクエストを100%シャットアウトしています。

ポイント⑥:XSSを完全に防ぐ htmlspecialchars の鉄板指定

入力値を画面に表示するヘルパー関数 h() では、以下の3要素を確実に指定しています:

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

模範解答のコードが手元で動いたら、実務レベルに近づけるための発展課題にチャレンジしてみましょう!

発展課題A:電話番号(ハイフンあり・なし両対応)の検証を追加

フォーム項目に「電話番号(tel)」を追加し、以下の仕様でバリデーションを拡張してみてください:

発展課題B:HTMLフォームと連動させた「入力値の保持(Sticky Form)」

バリデーションでエラーが発生して入力画面に戻った際、「ユーザーが入力した内容が消えずにフォームに残っている」ようにしてみましょう。

<!-- 入力値を保持しつつ安全に出力する例 -->
<input type="text" name="name" value="<?= h($input['name'] ?? '') ?>">
<?php if (isset($errors['name'])): ?>
    <p class="error"><?= h($errors['name']) ?></p>
<?php endif; ?>

h() 関数を通さずに value に出力すると、入力欄そのものがXSSの攻撃ベクターになります。エスケープの重要性を実感できるはずです。

発展課題C:オブジェクト指向(バリデータクラス)へのリファクタリング

現在は単一の関数の中にすべての検証ルールが書かれていますが、フォームが増えると同じようなチェックロジックが重複してしまいます。
Validator クラスを作成し、$validator->required('name')->maxLength('name', 50)->email('email'); のように流れるようなインターフェース(Fluent Interface)でルールを宣言できる設計に挑戦してみましょう!

バリデーションは、ユーザーからの入力を受け取るすべてのWebアプリケーションの「最初の防壁」です。ここで手抜きをしないことが、安全で信頼されるWebエンジニアへの第一歩になります。ぜひ手元でコードを書き換えて試してみてくださいね!