フォームと入力検証の実装例
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>