フォームと入力検証の実装例

NaifyFormとSession::form()・Session::csrf()を組み合わせた、確認画面付きの会員登録フォーム(入力→確認→登録→完了)の実装例を紹介します。全体コードを見たあと、各セクションで何をやっているかを順番に確認します。

このページでは追加する処理を紹介します。ファイル配置と公開入口、BaseController、NaifyViewの初期化とgetView()、Sessionの開始を先に用意します。SampleAppBootstrap.phpではNaifyForm::boot()を呼び出しておきます。

全体コード(SignupController)

新規会員登録の入力受付(indexAction)、入力検証と確認画面(confirmAction)、DBへの登録実行(commitAction)、完了画面(completeAction)を1つのControllerにまとめたシンプルな構成です。

<?php
declare(strict_types=1);

namespace App\Controllers\Register;

use App\Controllers\BaseController;
use App\Models\UserRepository;
use Naify\Session\Session;
use NaifyForm\NaifyForm;

final class SignupController extends BaseController
{
    /**
     * 1. 入力画面を表示する(検証エラー時の再表示も兼ねる)
     */
    public function indexAction(array $errors = []): void
    {
        $this->getView()->setTemplate('register/signup-form.html');
        $this->getView()->assign('input', Session::form('signup')->get())
            ->assign('errors', $errors)
            ->assign('csrfToken', Session::csrf()->getToken());
    }

    /**
     * 2. 入力を検証し、確認画面を表示する
     */
    public function confirmAction(): void
    {
        Session::form('signup')->reset();

        $form = NaifyForm::create($this->context->getParamAll('POST'));
        $form->set('_csrf_token')->isToken(Session::csrf()->getToken());
        $form->set('name', 'お名前')->required()->isString()->length(null, 50);
        $form->set('kana', 'フリガナ')->required()->isString()->isKana()->length(null, 50);
        $form->set('email', 'メールアドレス')->required()->isString()->isEmail()->length(null, 123);
        $form->set('username', 'ログインID')->required()->isString()->length(null, 50)->isHankakuAlnum();
        $form->set('password', 'パスワード')->required()->isString()->length(6, 72)->isHankakuAlnum();
        $form->set('password_confirm', '確認用パスワード')->required()->isString()->sameField('password');

        $input = $form->getInput();
        Session::form('signup')->store($input);

        if ($form->validate()->hasError()) {
            $this->indexAction($form->getMessages());
            return;
        }
        if (UserRepository::existsLoginId($input['username'])) {
            $form->addError('username', 'このログインIDはすでに登録されています。');
        }
        if (UserRepository::existsEmail($input['email'])) {
            $form->addError('email', 'このメールアドレスはすでに登録されています。');
        }
        if ($form->hasError()) {
            $this->indexAction($form->getMessages());
            return;
        }

        $this->getView()->setTemplate('register/signup-confirm.html');
        $this->getView()->assign('input', $input)
            ->assign('formToken', Session::form('signup')->verified())
            ->assign('csrfToken', Session::csrf()->getToken());
    }

    /**
     * 3. 確認済みデータで会員を登録し、完了画面へリダイレクトする
     */
    public function commitAction(): void
    {
        $token = $this->context->getParam('_form_token', null, 'POST');
        $csrf = $this->context->getParam('_csrf_token', null, 'POST');
        if (!Session::csrf()->verify($csrf) || !Session::form('signup')->verify($token)) {
            $this->indexAction([
                '_form_token' => ['画面の有効期限が切れた可能性があります。もう一度入力してください。'],
            ]);
            return;
        }

        UserRepository::addUser(Session::form('signup')->get());
        Session::form('signup')->complete();
        $this->context->response->redirect('/register/complete', 303);
    }

    /**
     * 4. 完了状態を確認して完了画面を表示する
     */
    public function completeAction(): void
    {
        if (!Session::form('signup')->isCompleted()) {
            $this->context->response->redirect('/register');
            return;
        }

        $this->getView()->setTemplate('register/signup-complete.html');
        $this->getView()->assign('input', Session::form('signup')->get());
    }
}

1. 入力画面の表示と再表示(indexAction)

初回の入力画面表示と、検証エラー時の再表示を同じメソッドで担当しています。

public function indexAction(array $errors = []): void
{
    $this->getView()->setTemplate('register/signup-form.html');
    $this->getView()->assign('input', Session::form('signup')->get())
        ->assign('errors', $errors)
        ->assign('csrfToken', Session::csrf()->getToken());
}
  • Session::form('signup')->get():セッションに一時保存されている入力値を取り出します。初回アクセス時は空配列[]が返り、確認画面から「戻る」で戻ったときや検証エラー時には直前の入力値がそのまま復元されます。
  • $errors:確認処理(confirmAction)でエラーが見つかった場合、$this->indexAction($form->getMessages())のように項目別エラー配列を渡してそのまま入力画面を再描画します。
  • Session::csrf()->getToken():セッションに紐づくCSRFトークンを取得(未発行なら自動生成)し、フォームのhidden項目へ埋め込むためにテンプレートへ渡します。

2. 入力検証・重複チェック・確認画面(confirmAction)

POSTされた値の形式チェック、DBとの重複確認、確認画面用トークンの発行を行います。

Session::form('signup')->reset();

$form = NaifyForm::create($this->context->getParamAll('POST'));
$form->set('_csrf_token')->isToken(Session::csrf()->getToken());
$form->set('name', 'お名前')->required()->isString()->length(null, 50);
$form->set('kana', 'フリガナ')->required()->isString()->isKana()->length(null, 50);
$form->set('email', 'メールアドレス')->required()->isString()->isEmail()->length(null, 123);
$form->set('username', 'ログインID')->required()->isString()->length(null, 50)->isHankakuAlnum();
$form->set('password', 'パスワード')->required()->isString()->length(6, 72)->isHankakuAlnum();
$form->set('password_confirm', '確認用パスワード')->required()->isString()->sameField('password');
  • reset()とNaifyForm::create():前回のフォーム状態をいったん初期化し、POSTパラメータ全体を渡してフォーム定義を始めます。
  • set()による項目とルールの定義:受け付ける項目名・表示ラベル・検証ルール(必須、文字列、文字数、カナ、メール形式、半角英数字、パスワード一致など)をメソッドチェーンで宣言します。CSRFトークンもisToken()で同じ仕組みの上で検証できます。
$input = $form->getInput();
Session::form('signup')->store($input);

if ($form->validate()->hasError()) {
    $this->indexAction($form->getMessages());
    return;
}
if (UserRepository::existsLoginId($input['username'])) {
    $form->addError('username', 'このログインIDはすでに登録されています。');
}
if (UserRepository::existsEmail($input['email'])) {
    $form->addError('email', 'このメールアドレスはすでに登録されています。');
}
if ($form->hasError()) {
    $this->indexAction($form->getMessages());
    return;
}
  • getInput()とstore($input):set()で定義したキーだけを抽出し、未検証状態としてセッションへ一時保存します。ここで先に保存しておくことで、検証エラーでindexAction()へ戻ったときに入力値が消えません。
  • 2段階のエラー判定:まず$form->validate()->hasError()で単項目の書式ルールを検証します。書式が通った場合のみDBの重複チェック(existsLoginId / existsEmail)を実行し、重複があれば$form->addError()で同じエラー配列に合流させます。
$this->getView()->setTemplate('register/signup-confirm.html');
$this->getView()->assign('input', $input)
    ->assign('formToken', Session::form('signup')->verified())
    ->assign('csrfToken', Session::csrf()->getToken());
  • Session::form('signup')->verified():すべての検証を通過した時点で呼び出し、セッション内の入力データを「検証通過済み」として確定させ、登録実行(commit)用のワンタイムトークン(formToken)を発行します。

3. トークン照合と本登録(commitAction)

確認画面から「登録する」が押されたときの処理です。POSTで新しく入力値を受け取るのではなく、確認画面で検証済みとしてセッションに保持したデータを登録します。

public function commitAction(): void
{
    $token = $this->context->getParam('_form_token', null, 'POST');
    $csrf = $this->context->getParam('_csrf_token', null, 'POST');
    if (!Session::csrf()->verify($csrf) || !Session::form('signup')->verify($token)) {
        $this->indexAction([
            '_form_token' => ['画面の有効期限が切れた可能性があります。もう一度入力してください。'],
        ]);
        return;
    }

    UserRepository::addUser(Session::form('signup')->get());
    Session::form('signup')->complete();
    $this->context->response->redirect('/register/complete', 303);
}
  • Session::csrf()->verify()とSession::form('signup')->verify():CSRFトークンと、確認画面で発行した_form_tokenの両方を検証します。確認画面を通らない直接POSTや、有効期限切れ、ブラウザ連打による二重送信はここで弾かれます。
  • Session::form('signup')->get()で本登録:確認画面で検証を通過したセッション内の入力データを取り出してUserRepository::addUser()へ渡します。
  • complete()と303 See Otherリダイレクト:complete()を呼ぶと検証トークンが無効化(消費)され、完了状態にロックされます。そのうえで完了画面へリダイレクトします。

4. 完了画面の表示(completeAction)

リダイレクト先の完了画面です。完了画面でブラウザをリロードしても、登録処理が再実行されることはありません。

public function completeAction(): void
{
    if (!Session::form('signup')->isCompleted()) {
        $this->context->response->redirect('/register');
        return;
    }

    $this->getView()->setTemplate('register/signup-complete.html');
    $this->getView()->assign('input', Session::form('signup')->get());
}
  • isCompleted()による直アクセス防止:commitActionでcomplete()が呼ばれていない状態でURLを直接開かれた場合は、入力画面へ戻します。
  • 完了画面での入力値参照:complete()はトークンだけを消費して入力データ自体は残すため、完了画面でも「登録したメールアドレスやログインID」をSession::form('signup')->get()から安全に表示できます。

ルート定義とテンプレートの例

上記の4つのアクションをroutes/routes.phpに登録します。クラスやメソッドに#[Route('/register/confirm')]のようなPHP属性(Attribute)を付けておき、アプリのルート構成に合わせて対応付ける形でも整理できます。

// routes/routes.php
$router->get('/register/[index]', $to('Register\\SignupController::index'));
$router->post('/register/confirm', $to('Register\\SignupController::confirm'));
$router->post('/register/commit', $to('Register\\SignupController::commit'));
$router->get('/register/complete', $to('Register\\SignupController::complete'));

入力画面と確認画面のテンプレートでは、トークンと各項目を次のように配置します。

<!-- views/register/signup-form.html(入力画面の抜粋) -->
<form method="post" action="/register/confirm">
  <input type="hidden" name="_csrf_token" value="{{ csrfToken.escape() }}">

  <nf-for-each exp="msg in errors._form_token">
    <p class="error">{{ msg.escape() }}</p>
  </nf-for-each>

  <div>
    <label>ログインID</label>
    <input type="text" name="username" value="{{ input.username.escape() }}">
    <nf-for-each exp="msg in errors.username">
      <p class="error">{{ msg.escape() }}</p>
    </nf-for-each>
  </div>

  <button type="submit">確認画面へ進む</button>
</form>

<!-- views/register/signup-confirm.html(確認画面の抜粋) -->
<dl>
  <dt>ログインID</dt>
  <dd>{{ input.username.escape() }}</dd>
</dl>

<form method="post" action="/register/commit">
  <input type="hidden" name="_csrf_token" value="{{ csrfToken.escape() }}">
  <input type="hidden" name="_form_token" value="{{ formToken.escape() }}">
  <a href="/register">入力画面に戻る</a>
  <button type="submit">この内容で登録する</button>
</form>