Laravel new
Laravel + Inertia.jsのForm Helperを使った際のエラーハンドリングをより実践的に
こんにちは、ナカエです。 今年の京都は雪が多めで寒い日が続いています。
弊社では最近Laravelと Inertia.js によるMPAを選択するプロジェクトが増えて知見を溜めつつあり、当ブログでも紹介していければと考えています。
Inertia.jsを使う場合のフォームの実装には、Form Helper を使うのが手軽です。本日はこのForm Helperを使った際のエラーハンドリングについて、ドキュメントから一歩先に進んで検討した例を紹介します。
環境
バックエンド
- PHP 8.4.4
- Laravel 11.31
- inertiajs/inertia-laravel 2.01
フロントエンド
今回はサンプルをVueとしていますが、ReactやSvelteを使う場合もほぼ同様の実装が可能です。
- Node 22.5.1
- @inertiajs/vue3 2.0.3
- Vue 3.5.13
- ziggy-js 2.5.1
今回のサンプルとなるフォーム
イベントを新規登録する簡単なフォームを考えます。イベントのタイトルと開始日時を登録するものとします。
エラー発生時に期待される挙動は下記のとおりです。
- フォーム送信先のエンドポイントでバリデーションエラーが起こった際はリダイレクトバックし、バリデーションエラーメッセージはフォームの入力項目それぞれの下に表示する
- その他の例外が発生した場合もリダイレクトバックし、例外のメッセージをフォームの上部に表示する
バリデーションエラー発生時
その他の例外発生時
デフォルトのエラーハンドリング
まずは、何も設定していない状態の確認です。 Controllerなどでバリデーションエラー以外の例外を投げた場合は、Inertia.jsの用意しているモーダルの中にLaravelデフォルトのエラー画面が表示されてしまいます。この系統の挙動は一般的には事例が少なく、多くの場合はそのままでは好ましくありません。
Inertia.js公式で紹介されている本番向けの設定
Inertia.jsのドキュメントのエラーハンドリング には、本番環境向けの設定方法が紹介されています。
bootstrap/app.phpの例外ハンドリングの設定を追記するものです。
- ステータスコードが403,404,500,503のいずれかとなる例外発生時はフロントエンドのエラー用のページコンポーネントでエラー画面を表示する
- XSRFトークンのミスマッチが起こりステータスコードが419となった場合はリダイレクトバックしてセッションにメッセージを入れる
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Inertia\Inertia;
->withExceptions(function (Exceptions $exceptions) {
$exceptions->respond(function (Response $response, Throwable $exception, Request $request) {
if (! app()->environment(['local', 'testing']) && in_array($response->getStatusCode(), [500, 503, 404, 403])) {
return Inertia::render('Error', ['status' => $response->getStatusCode()])
->toResponse($request)
->setStatusCode($response->getStatusCode());
} elseif ($response->getStatusCode() === 419) {
return back()->with([
'message' => 'The page expired, please try again.',
]);
}
return $response;
});
})
resoures/js/Pages/Error.vue にコンポーネントを用意して、フロントエンド側で好きにカスタマイズできます。
ただこの手法だけだと、例外発生時にフォームのある画面にリダイレクトバックしてエラーを表示したいという要望には対応しづらいです。 該当するControllerのメソッドで明示的にcatchしてリダイレクトレスポンスに変換する必要が出てきます。
Laravel+Inertia.jsのデフォルトの設定として、バリデーションエラー発生時はリダイレクトバックする挙動になっています。 他の例外発生時も共通処理としてこの挙動に揃えたほうがアプリケーションのユーザーとしては使いやすいのでは?という発想が出発点です。
エラーハンドリングの期待値
例外ハンドリングをInertiaからのリクエストとそれ以外に分けて考えます。
Inertiaからのリクエストで例外が発生した場合
- バリデーションエラーが発生した場合はデフォルトの挙動でOK(リダイレクトバックしてエラー情報がForm Helperで扱える形になる)
- その他の例外が発生した場合は、リダイレクトバックしてフォーム画面にエラー情報(今回は例外のメッセージ)を表示できるようにする
※ Inertiaからのリクエストかどうかは、ヘッダのX-Inertiaで判定します。
Inertia以外のリクエストで例外が発生した場合
画面URLへの直アクセスなどは、リダイレクトバックするとまずいので別の挙動とします。
- 本番環境を除くデバッグモード時はLaravelデフォルトのCollisionなどでエラーの詳細を表示させる
- その他の場合はフロントエンドのページコンポーネントでエラー画面を表示する(リダイレクトバックすると問題がある)
実装コード
下記に実装の一部を示します。
bootstrap/app.php
個人的にはbootstrap/app.phpに全てを記述するより、個々に例外ハンドラなどを自作する方向性の方が好みなのですが、時代の流れに従ってこちらに定義します。
Responseの必要ない挙動はExeptions::render()にて、必要な挙動はExceptions::respond()にて定義しています。
ポイントはRedirectResponse::withErrors()にて _app
というキーの例外メッセージを含めていることです。こちらで設定されるエラーが空ではない場合、フォームヘルパーのpost()などの第二引数に設定できるオプションのonError()のコールバックに入ります。エラーを設定しない場合はonSuccess()のコールバックに入るため、扱いづらくなります。
一点、本番利用するにはもう一工夫が必要で、大抵の場合はすべての例外のメッセージをユーザーに見せるにはいかないはずです。特定のインターフェースを実装している例外以外は共通のエラーメッセージにするなどの分岐処理が必要でしょう。
// 略
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (Throwable $e, Request $request) {
// バリデーションエラーはデフォルトの挙動で良いのでスルー
if ($e instanceof ValidationException) {
return null;
}
// Inertiaによるリクエストの場合に限定して挙動を変える
// つまりURL直アクセスの場合などはスルー
if (!$request->header('X-Inertia')) {
return null;
}
// XSRFトークンミスマッチのエラーはメッセージを変更したい
// デバッグオフの際には詳細なエラー情報を表示しないなどの要件がある場合もここで分岐すると良い
$errorMessage =
$e instanceof HttpExceptionInterface &&
$e->getStatusCode() === 419
? 'ページの有効期限が切れています。もう一度お試しください。'
: $e->getMessage();
return back()
->withErrors(
[
// 空ではなくすことで、フロント側のInertiaでerror側の分岐に入れる
'_app' => [
$errorMessage,
],
]
)
->withInput()
->with(
[
// フォーム入力を汚したくない場合、フラッシュメッセージ経由でエラーメッセージを表示する手もあり
'messages.danger' => $errorMessage,
]
);
});
$exceptions->respond(function (Response $response, Throwable $e, Request $request) {
// 本番環境を除くデバッグモード時はcollisionでエラーの詳細を表示したいのでそのまま
if (!app()->isProduction() && app()->hasDebugModeEnabled()) {
return $response;
}
// リダイレクトもそのまま
if ($response->getStatusCode() < 400) {
return $response;
}
// 通常はInertiaページコンポーネントによりエラー画面を表示
return Inertia::render('Error', ['status' => $response->getStatusCode()])
->toResponse($request)
->setStatusCode($response->getStatusCode());
});
})
// 略
Http/Controllers/EventController.php
フォームのイベントタイトルの入力を ‘error’ とした時のみ、バリデーションエラー以外の一般の例外が発生するようにしています。
※ 単にサンプルの動作確認のためであり、本番仕様のアプリケーションでこのような要求がある場合はFormRequestによるバリデーションで対処すべきです。
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\InvalidEventTitleException;
use App\Http\Requests\StoreEventRequest;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class EventController
{
// 略
public function create(): Response
{
return Inertia::render('Events/CreateEvent');
}
public function store(StoreEventRequest $request): RedirectResponse
{
if ($request->validated('title') === 'error') {
throw new InvalidEventTitleException('不適切なイベントタイトルです。');
}
return redirect(route('events.index'))
->with(['messages.success' => 'イベントを新規登録しました。']);
}
// 略
}
resources/js/Pages/Events/CreateEvent.vue
onSuccess()、onError()が正常に機能することを確認するためにデバッグ用のコンソールへのロギングを含めています。 補完のため、useForm()のデータに _app
のキーを含めていますが、より丁寧に実装するならフォームの入力は汚さずにフラッシュメッセージをエラーとして表示するコンポーネントを作成するなどで対処すべきでしょう。
<script setup>
import { Head, useForm } from '@inertiajs/vue3'
import { route } from "ziggy-js";
const form = useForm(
{
title: null,
startDate: null,
_app: null,
}
);
const handleSubmit = () => {
form.post(route('events.store'), {
preserveScroll: true,
preserveState: 'errors',
onSuccess: (page) => {
console.log('success', page);
},
onError: (error) => {
console.log('error', error);
},
});
};
</script>
<template>
<Head title="イベント作成" />
<main :class="$style.container">
<h1>イベント作成</h1>
<!-- フォーム全体のエラー -->
<div v-if="form.errors._app" :class="$style.formError">
{{ form.errors._app }}
</div>
<form @submit.prevent="handleSubmit">
<div :class="$style.formControl">
<label>
タイトル
<input type="text" v-model="form.title" placeholder="タイトル" />
</label>
<div v-if="form.errors.title" :class="$style.formError">
{{ form.errors.title }}
</div>
</div>
<div :class="$style.formControl">
<label>
開始日時
<input type="date" v-model="form.startDate" />
</label>
<div v-if="form.errors.startDate" :class="$style.formError">
{{ form.errors.startDate }}
</div>
</div>
<div :class="$style.formControl">
<button type="submit">
作成
</button>
</div>
</form>
</main>
</template>
<style module>
.container {
padding: 1em;
}
.formControl {
margin-top: 1em;
}
.formError {
color: red;
font-size: 80%;
}
</style>
例外発生時にonError()のコールバックが呼ばれていることも確認できます。
まとめ
LaravelとInertia.jsを使ったMPAのフォームにおいて、SPAのようにフォーム画面でエラーを表示するようにするための実装コードの例を紹介しました。 Inertia.jsが後付けなので複雑になりがちですが、まずは挙動の期待値を場合分けして要求仕様を先に整理することが重要ですね。
- PHP , Laravel , Inertia.js