【PRGパターン入門】フォームの二重送信を防ぐ基本設計

章: 2章

技術タグ: PHP POST PRGパターン フォーム

今回やること

前回は、PHP側で入力チェックを行い、エラーがあればフォームに戻す処理を作りました。

これで、JavaScriptに頼らずサーバー側で入力内容を確認できるようになりました。

今回は、フォーム送信後の設計をさらに改善します。

テーマは、フォームの二重送信です。

フォームは送って終わりではありません。

送信後にブラウザで再読み込みした時、戻るボタンを押した時、同じデータがもう一度送られないかまで考える必要があります。

フォーム送信
↓
POSTで処理
↓
そのまま結果表示
↓
再読み込み
↓
同じPOSTが再送信される可能性

この問題を防ぐ基本設計が、PRGパターンです。


今回のゴール

今回のゴールは、以下です。

  • フォームの二重送信が起きる理由を知る
  • POST後に直接画面表示する危険を理解する
  • PRGパターンの考え方を知る
  • POST処理後にthanks.phpへリダイレクトする
  • 完了画面をGETで表示する
  • 再読み込みしてもPOST再送信が起きにくい構成にする

ここまでできると、フォーム処理はかなり実務的な形に近づきます。


二重送信とは何か

二重送信とは、同じフォームデータが意図せず複数回送信されてしまうことです。

たとえば、お問い合わせフォームなら、同じお問い合わせが2件登録されたり、同じメールが2通送られたりする可能性があります。

よくあるきっかけは、送信後の再読み込みです。

送信ボタンを押す
↓
POST送信される
↓
確認画面が表示される
↓
その画面で再読み込みする
↓
ブラウザがPOSTを再送信しようとする

ブラウザによっては、「フォームを再送信しますか?」のような確認が出ることがあります。

これは、今いるページがPOSTリクエストの結果として表示されているためです。


POST後に直接画面表示する問題

前回までの形では、receive.php がPOSTを受け取り、そのまま確認画面を表示していました。

流れとしてはこうです。

POST送信
↓
receive.phpで処理
↓
そのまま確認画面を表示
↓
ブラウザで再読み込み
↓
同じPOSTがもう一度送られる可能性

この形でも、見た目上は動きます。

しかし、送信後の画面がPOSTの結果として表示されているため、再読み込み時にPOSTが再送信される可能性があります。

もしこのタイミングでメール送信やDB保存をしていたら、同じ処理が重複する恐れがあります。


PRGパターンとは何か

PRGパターンとは、Post Redirect Get の略です。

フォーム処理でよく使われる基本設計です。

POST
↓
Redirect
↓
GET
段階役割
POSTフォームデータをサーバーへ送る
Redirect処理後に別ページへ移動させる
GET完了画面を通常のページ表示として開く

ポイントは、POST処理のあとに直接画面を表示しないことです。

POSTで処理したら、完了画面へリダイレクトします。

完了画面はGETで表示します。


PRGにすると何が良いのか

PRGにすると、送信後の再読み込みでPOSTが再送信されにくくなります。

流れはこうです。

POST送信
↓
receive.phpで処理
↓
thanks.phpへリダイレクト
↓
thanks.phpをGETで表示
↓
再読み込みしてもGETの再読み込みになる

ユーザーが見ている最終画面は、POSTの結果画面ではなく、GETで開いた thanks.php です。

そのため、完了画面で再読み込みしても、基本的には thanks.php が再読み込みされるだけです。

同じPOST処理が繰り返される危険を減らせます。


今回のファイル構成

今回は、完了画面用の thanks.php を追加します。

contact-form-app/
├─ index.php
├─ receive.php
├─ thanks.php
└─ style.css
ファイル役割
index.phpフォーム表示・エラー表示
receive.phpPOST受信・入力チェック・成功時にリダイレクト
thanks.php送信完了画面をGETで表示
style.cssフォーム・エラー・完了画面の見た目を整える

今回の主役は、receive.php の成功時に thanks.php へリダイレクトする部分です。


index.phpは前回と同じ

index.php は、前回のPHPバリデーション回とほぼ同じです。

エラーがあれば表示し、入力済みの値を戻します。

<?php
/**
 * index.php
 *
 * 役割:
 * - お問い合わせフォームを表示する
 * - GETパラメータで渡されたエラーメッセージを表示する
 * - 送信先の receive.php へPOST送信する
 */

$errors = $_GET['errors'] ?? [];
$name = $_GET['name'] ?? '';
$email = $_GET['email'] ?? '';
$message = $_GET['message'] ?? '';

if (!is_array($errors)) {
    $errors = [];
}

/**
 * HTML出力用に文字列をエスケープする
 *
 * @param string $value 画面に表示したい文字列
 * @return string エスケープ済みの文字列
 */
function escape_html($value)
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>お問い合わせフォーム</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="contact-page">
    <h1 class="contact-page__title">お問い合わせフォーム</h1>

    <p class="contact-page__lead">
      ご質問やご相談があれば、以下のフォームから送信してください。
    </p>

    <?php if (!empty($errors)) : ?>
      <div class="error-box">
        <p class="error-box__title">入力内容を確認してください。</p>
        <ul class="error-box__list">
          <?php foreach ($errors as $error) : ?>
            <li><?php echo escape_html($error); ?></li>
          <?php endforeach; ?>
        </ul>
      </div>
    <?php endif; ?>

    <form class="contact-form" action="receive.php" method="post">
      <div class="contact-form__field">
        <label class="contact-form__label" for="name">お名前</label>
        <input class="contact-form__control" type="text" id="name" name="name" value="<?php echo escape_html($name); ?>" />
      </div>

      <div class="contact-form__field">
        <label class="contact-form__label" for="email">メールアドレス</label>
        <input class="contact-form__control" type="email" id="email" name="email" value="<?php echo escape_html($email); ?>" />
      </div>

      <div class="contact-form__field">
        <label class="contact-form__label" for="message">お問い合わせ内容</label>
        <textarea class="contact-form__control contact-form__textarea" id="message" name="message"><?php echo escape_html($message); ?></textarea>
      </div>

      <button class="contact-form__button" type="submit">
        送信する
      </button>
    </form>
  </main>
</body>
</html>

ここでは、フォームの送信先を receive.php にしています。

<form class="contact-form" action="receive.php" method="post">

フォームはPOSTで receive.php に送信されます。


receive.phpをPRG対応にする

次に、receive.php を修正します。

エラーがある時は、これまで通り index.php へ戻します。

問題がない時は、確認画面を直接表示せず、thanks.php へリダイレクトします。

<?php
/**
 * receive.php
 *
 * 役割:
 * - フォームからPOST送信された値を受け取る
 * - サーバー側で入力チェックする
 * - エラーがあればフォーム画面へ戻す
 * - 問題なければ完了画面へリダイレクトする
 *
 * PRGパターン:
 * POSTで受け取った後、直接画面表示せずに thanks.php へリダイレクトする
 */

$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$message = trim($_POST['message'] ?? '');

$errors = [];

if ($name === '') {
    $errors[] = 'お名前を入力してください。';
}

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

if ($message === '') {
    $errors[] = 'お問い合わせ内容を入力してください。';
}

if (!empty($errors)) {
    $query = http_build_query([
        'errors' => $errors,
        'name' => $name,
        'email' => $email,
        'message' => $message,
    ]);

    header('Location: index.php?' . $query);
    exit;
}

/**
 * 本来はここでメール送信やDB保存を行う
 *
 * 例:
 * - 管理者へメール送信
 * - データベースへ保存
 * - ログを残す
 */

// POST処理が成功したら、完了画面へリダイレクトする
header('Location: thanks.php');
exit;

成功時の中心は、この部分です。

header('Location: thanks.php');
exit;

header('Location: thanks.php') で完了画面へ移動させています。

その直後に exit を書くことで、以降の処理を確実に止めます。


本来の処理はリダイレクト前に行う

実務では、バリデーションを通過したあと、リダイレクト前に必要な処理を行います。

たとえば、以下のような処理です。

  • 管理者へメール送信
  • データベースへ保存
  • ログを残す
  • 外部APIへ送信

今回のコードでは、まだメール送信やDB保存は行っていません。

そのため、コメントで処理位置だけ示しています。

本来はここでメール送信やDB保存を行う
↓
完了したら thanks.php へリダイレクト

処理が成功してから完了画面へ移動する、という順番が大事です。


thanks.phpを作る

次に、送信完了画面を作ります。

thanks.php に以下を書きます。

<?php
/**
 * thanks.php
 *
 * 役割:
 * - フォーム送信後の完了画面を表示する
 * - GETリクエストで表示されるため、再読み込みしてもPOSTの再送信が起きにくい
 */
?>
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>送信完了</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body>
  <main class="contact-page">
    <h1 class="contact-page__title">送信完了</h1>

    <div class="result-box">
      <p class="result-box__value">
        お問い合わせを受け付けました。
      </p>

      <p class="result-box__value">
        内容を確認のうえ、必要に応じてご連絡いたします。
      </p>

      <a class="result-box__link" href="index.php">フォームへ戻る</a>
    </div>
  </main>
</body>
</html>

thanks.php は、フォーム送信後に表示する完了画面です。

このページはPOSTを受け取るページではありません。

リダイレクト後にGETで表示されるページです。

receive.php
↓ リダイレクト
thanks.php

この構成により、完了画面を再読み込みしてもPOST処理が再実行されにくくなります。


style.cssは前回と同じでOK

今回のCSSは、前回のものをそのまま使えます。

必要なら以下を使ってください。

:root {
  --color-bg: #f5f5f5;
  --color-text: #222222;
  --color-surface: #ffffff;
  --color-border: #cccccc;
  --color-muted: #666666;
  --color-error: #c0392b;
  --color-error-bg: #fff5f5;

  --space-s: 8px;
  --space-m: 16px;
  --space-l: 24px;
  --space-xl: 32px;
  --space-2xl: 64px;

  --radius-m: 8px;
  --radius-l: 16px;
  --radius-pill: 999px;

  --font-base: sans-serif;
}

body {
  margin: 0;
  font-family: var(--font-base);
  background-color: var(--color-bg);
  color: var(--color-text);
}

.contact-page {
  max-width: 640px;
  margin-inline: auto;
  padding-block: var(--space-2xl);
  padding-inline: var(--space-l);
}

.contact-page__title {
  margin-block-start: 0;
  margin-block-end: var(--space-m);
  font-size: 32px;
  text-align: center;
}

.contact-page__lead {
  margin-block-start: 0;
  margin-block-end: var(--space-xl);
  line-height: 1.8;
  text-align: center;
}

.contact-form,
.result-box,
.error-box {
  display: grid;
  gap: var(--space-l);
  padding-block: var(--space-xl);
  padding-inline: var(--space-l);
  border-radius: var(--radius-l);
  background-color: var(--color-surface);
}

.error-box {
  margin-block-end: var(--space-l);
  border-width: 1px;
  border-style: solid;
  border-color: var(--color-error);
  background-color: var(--color-error-bg);
  color: var(--color-error);
}

.error-box__title {
  margin-block-start: 0;
  margin-block-end: 0;
  font-weight: bold;
}

.error-box__list {
  margin-block-start: 0;
  margin-block-end: 0;
  padding-inline-start: var(--space-l);
}

.contact-form__field,
.result-box__item {
  display: grid;
  gap: var(--space-s);
}

.contact-form__label,
.result-box__label {
  font-weight: bold;
}

.contact-form__control {
  width: 100%;
  padding-block: 12px;
  padding-inline: 12px;
  border-width: 1px;
  border-style: solid;
  border-color: var(--color-border);
  border-radius: var(--radius-m);
  font-size: 16px;
  box-sizing: border-box;
}

.contact-form__textarea {
  min-height: 160px;
  resize: vertical;
}

.contact-form__button,
.result-box__link {
  display: inline-block;
  padding-block: 14px;
  padding-inline: var(--space-l);
  border-width: 0;
  border-radius: var(--radius-pill);
  background-color: var(--color-text);
  color: var(--color-surface);
  font-size: 16px;
  font-weight: bold;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
}

.contact-form__button:hover,
.result-box__link:hover {
  opacity: 0.8;
}

.result-box__value {
  margin-block-start: 0;
  margin-block-end: 0;
  color: var(--color-muted);
  line-height: 1.8;
  white-space: pre-wrap;
}

今回は見た目よりも、送信後の流れが主役です。


実際に動かしてみる

Laragonで以下のURLを開きます。

http://contact-form-app.test/

フォームに正しい内容を入力して送信します。

うまくいけば、thanks.php に移動し、送信完了画面が表示されます。

この時、ブラウザのURLも確認してください。

http://contact-form-app.test/thanks.php

送信後の最終画面が receive.php ではなく thanks.php になっていれば、PRGパターンの形になっています。


再読み込みしてみる

thanks.php が表示された状態で、ブラウザを再読み込みしてみます。

PRGにしていれば、再読み込みされるのは thanks.php です。

フォームのPOST処理がもう一度実行される状態ではありません。

ここが重要です。

送信完了画面で再読み込み
↓
GETページの再読み込み
↓
POST再送信ではない

これにより、同じお問い合わせが二重に登録されたり、同じメールが再送信されたりする危険を減らせます。


DevToolsのNetworkで見る

DevToolsのNetworkタブを開いた状態で送信してみると、流れが見えます。

見るポイントは以下です。

  • receive.php へPOSTしている
  • receive.php から thanks.php へリダイレクトしている
  • thanks.php がGETで表示されている

Networkで見ると、PRGパターンの流れがかなり理解しやすくなります。

POST receive.php
↓
302 Redirect
↓
GET thanks.php

ステータスコードは環境によって多少見え方が変わる場合がありますが、リダイレクトして完了画面へ移動していることが確認できればOKです。


curlでも確認できる

Git Bashからcurlでも確認できます。

curl -i -X POST http://contact-form-app.test/receive.php \
  -d "name=Leon" \
  -d "email=leon@example.com" \
  -d "message=PRGテストです" 

-i を付けているので、レスポンスヘッダーも確認できます。

成功時には、Location: thanks.php のようなヘッダーが返るはずです。

これは、PHPがブラウザに対して「次はthanks.phpへ移動して」と伝えている状態です。


PRGは何を防いでいるのか

PRGは、フォーム送信後の再読み込みによる二重送信を防ぎやすくする設計です。

ただし、完全にすべての重複送信を防げるわけではありません。

たとえば、送信ボタンを連打されたり、別タブから何度も送られたりするケースもあります。

本格的には、次のような対策も組み合わせます。

  • 送信ボタンの二度押し防止
  • トークンによる送信チェック
  • DB側で重複登録を防ぐ
  • 同じ内容の連続送信を制限する
  • ログやIPを見て制御する

それでも、PRGはフォーム処理の基本としてかなり重要です。

まずは、POST後に直接画面表示しないという設計を身につけるのが大事です。


LG流:送信後の出口を設計する

フォーム処理では、送信ボタンを押した瞬間だけを考えてはいけません。

送信後に何が起きるかまで設計する必要があります。

入力する
↓
送信する
↓
サーバーで処理する
↓
完了画面へ移動する
↓
再読み込みしても安全な状態にする

LG流で言うなら、フォーム処理は出口設計までがセットです。

送ったら終わりではなく、送った後にユーザーが何をしても壊れにくい形にしておく。

これが、保守しやすく事故りにくいフォームの考え方です。


今回わかったこと

今回は、PRGパターンを使ってフォームの二重送信を防ぎやすい構成にしました。

学んだことは以下です。

  • POST後に直接画面表示すると再送信の危険がある
  • PRGは Post Redirect Get の略である
  • POST処理後は完了画面へリダイレクトする
  • 完了画面はGETで表示する
  • 再読み込み時のPOST再送信リスクを減らせる
  • header(‘Location: …’) でリダイレクトできる
  • リダイレクト後は exit で処理を止める
  • フォーム処理は送信後の出口まで設計する

これで、フォーム処理はかなり実務的な基本形に近づきました。


次回予告

次回は、フォームの送信内容をデータベースに保存します。

これまでは、受け取った内容を画面に表示するだけでした。

しかし実務では、お問い合わせ内容をあとから確認できるように保存することが重要です。

次回のテーマは、以下です。

【MySQL入門】お問い合わせ内容をデータベースに保存する

フォーム、PHPバリデーション、XSS対策、PRGパターン。

ここまで来たら、次はいよいよDB保存です。