Laravel 9.x フォームリクエストの解説

## はじめに

[Laravel Advent Calendar 2022](https://qiita.com/advent-calendar/2022/laravel) の3日目の投稿です。(遅れました...)

## 関連記事

https://qiita.com/ucan-lab/items/602f2667a185af490843

https://qiita.com/ucan-lab/items/4ea5955461471419dd9b

## 公式ドキュメント

https://readouble.com/laravel/9.x/ja/validation.html

## フォームリクエストとは

フォームリクエスト(FormRequest)は認可(Authorization)と検証(Validation)を行います。

フロントのHTMLフォームから渡ってくる入力データを検証したり、アクセスしたユーザーがそのリクエストを実行する権限があるかを確認できます。
コントローラで行っていた検証や認可の処理をフォームリクエストへ移譲できるので、コントローラの処理をシンプルにできる便利なクラスです。

## フォームリクエスト作成

フォームリクエストのテンプレートを作成するコマンドは用意されています。

```shell-session
$ php artisan make:request ArticleStoreRequest
```

デフォルトでは authorize() と rules() のメソッドが定義されています。

authorize: 認可の判定を行う
rules: バリデーションルールを連想配列で記述する

```php:app/Http/Requests/ArticlesStoreRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class ArticleStoreRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return false;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
        ];
    }
}
```

`authorize()` が認可のメソッドで、現在認証済みのユーザーがそのアクションを実行できるか判定します。
失敗すると403エラーレスポンスを返します。

`rules()`はバリデーションのメソッドで、リクエストのデータを検証するバリデーションルールを返します。
失敗すると302リダイレクトレスポンスor422エラーレスポンスを返します。

認可処理が不要な場合は `authorize()` を `true` で返さなくてもメソッドを削除すればokです。

https://github.com/laravel/framework/blob/9.x/src/Illuminate/Foundation/Http/FormRequest.php#L173-L189

### 補足: フォームリクエストの命名ルール

私の場合になりますが、命名ルールとしてはシングルアクションコントローラの名前をそのままフォームリクエストに採用します。

- ArticleStoreController => ArticleStoreRequest

### 補足: GETのフォームリクエストは必要か

HTTPメソッドがGETの場合は、特に検証することはないかもしれませんが認可では使用するので作っておいて良いと思います。

## フォームリクエストの認可

https://readouble.com/laravel/9.x/ja/authorization.html

https://readouble.com/laravel/9.x/ja/validation.html#authorizing-form-requests

```php:app/Http/Requests/ArticleStoreRequest.php
final class ArticleStoreRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return $this->user()->can(Permission::WriteArticles->value);
    }
}
```

記事の書き込み権限のないユーザーはアクセスできない認可処理を行っています。
`$this->user()` で認証済みユーザーへアクセスできます。

```php:app/Http/Requests/ArticleEditRequest.php
final class ArticleEditRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize(): bool
    {
        return $this->user()->can(Permission::WriteArticles->value) && $this->user()->id === $this->route('article')->user_id;
    }
}
```

`$this->route('article')->user_id` Articlesモデルのuser_idフィールドにアクセスしてます。(詳細はルートモデルバインディングの補足へ)

### 補足: 認可エラーメッセージのカスタマイズ

デフォルトだと `This action is unauthorized.` とエラーメッセージが表示されます。
メッセージを変更するには下記のメソッドをオーバーライドしてください。

```php
    /**
     * Handle a failed authorization attempt.
     *
     * @return void
     * @throws \Illuminate\Auth\Access\AuthorizationException
     */
    protected function failedAuthorization(): void
    {
        throw new AuthorizationException('記事の書き込み権限がありません。');
    }
```

### 補足: 認可の実装

例では [laravel-permission](https://github.com/spatie/laravel-permission) ライブラリを使用した認可処理をしています。
別記事にて紹介しているのでよければ参照ください。

https://qiita.com/ucan-lab/items/d1c561b0d7ca2c792fa1

### 補足: ルートモデルバインディング

```php
Route::get('articles/{article}/edit', [ArticleController::class, 'edit'])->name('articles.edit');
```

このようにルーティングが定義されている場合
`$this->route('article')` すると `App\Models\Article` とモデルの名前と一致するのでLaravelの[ルートモデルバインディング](https://readouble.com/laravel/9.x/ja/routing.html#route-model-binding)機能によってインスタンスが取得されます。

これが嫌な場合はこうしましょう。

```
Route::get('articles/{articleId}/edit', [ArticleController::class, 'edit'])->name('articles.edit');
```

`$this->route('articleId')` でモデルのインスタンスではなくURLに入力した内容がそのまま取得できます。

## フォームリクエストの検証(Validation)

```php:app/Http/Requests/TaskStoreRequest.php
final class TaskStoreRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'unique:posts', 'max:255'],
            // 'title' => 'required|unique:posts|max:255', // パイプライン区切りの文字列でも書ける
        ];
    }
}
```

バリデーションは `|` 区切りで書けますが、個人的には配列の方が見やすいので好きです。
配列に対してバリデーションしたい時は `.` で繋げます。

### 補足: 利用可能なバリデーションルール

バリデーションルールはたくさんあります。
公式ドキュメントを参考ください。

https://readouble.com/laravel/9.x/ja/validation.html#available-validation-rules

### 補足: 配列のバリデーション

#### キーなし

```
$data = [
    'article_ids' => [1, 5, 7],
]
```

```
    public function rules(): array
    {
        return [
            'article_ids' => ['required', 'array'],
            'article_ids.*' => ['integer'],
        ];
    }
```

#### キーあり

```
$data = [
    'author' => [
        'name' => 'ほげほげ',
        'description' => 'ぴよぴよ',
    ],
]
```

```
    public function rules(): array
    {
        return [
            'author' => ['required', 'array'],
            'author.name' => ['required', 'string', 'max:255'],
            'author.description' => ['required', 'string'],
        ];
    }
```

#### ネストした配列

```
$data = [
    'items' => [
        ['name' => 'ほげほげ', 'price' => 1000],
        ['name' => 'ぴよぴよ', 'price' => 2500],
    ],
]
```

```
    public function rules(): array
    {
        return [
            'items' => ['required', 'array'],
            'items.*.name' => ['required', 'string', 'max:255'],
            'items.*.price' => ['required', 'integer'],
        ];
    }
```

## フォームリクエストの使い方

```php:app/Http/Controllers/ArticlesController.php
<?php

namespace App\Http\Controllers;

use App\Http\Request\ArticleStoreRequest;
use Illuminate\Http\RedirectResponse;

final class ArticlesController extends Controller
{
    /**
     * 新しい記事を保存
     *
     * @param ArticleStoreRequest  $request
     * @return RedirectResponse
     */
    public function store(ArticleStoreRequest $request): RedirectResponse
    {
        $title = $request->input('title');

        // ...
    }
}
```

コントローラのメソッドに引数として定義するとLaravelのメソッドインジェクション機能により、フォームリクエストのインスタンスを受け取ることができます。

コントローラでフォームリクエストのインスタンスを受け取った時点でバリデーション済み、認可済みとなるので
コントローラではコントローラの処理に集中できます。

### 入力値の受け取り方

色んなメソッドがありますが、主に使用するものを抜粋してご紹介します。

```
// 入力値をすべて取得
$data = $request->all();

// 指定したフィールドを取得
$title = $request->input('title'); 
$title = $request->input('title', 'デフォルト値');
$title = $request->title; 

// 型を指定して取得
$value = $request->string('some_string_value');
$value = $request->boolean('some_boolean_value');
$value = $request->integer('some_int_value');
$value = $request->float('some_float_value');

// 指定したものを取り出す
$data = $request->only(['title', 'content']); 

// 指定したもの以外を取り出す
$data = $request->except(['title', 'content']); 

// rules() に定義したフィールドのみ取得
$data = $request->validated();

// rules() に定義したフィールドのみ
$request->safe(); // Illuminate\Support\ValidatedInput のインスタンス
$data = $request->safe()->only(['title', 'content']); 
$data = $request->safe()->only(['title', 'content']); 
```

`->safe()` は Laravel8.55以降の機能です。

https://zenn.dev/nshiro/articles/fdb5d716860404

`->integer()`, `->float()` は Laravel9.32以降の機能です。

https://zenn.dev/naopusyu/articles/000168b0db4d43

## フォームリクエスト毎にエラーメッセージの変更する

エラーメッセージは `lang/ja/validation.php` を利用することで日本語化してエラーメッセージを表示できます。

https://readouble.com/laravel/9.x/ja/validation.html#specifying-custom-messages-in-language-files

フォームリクエストによっては表示を個別に変更したい場合があります。

```php:app/Http/Requests/ArticleStoreRequest.php
final class ArticleStoreRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, mixed>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'unique:posts', 'max:255'],
            'author.name' => ['required'],
            'author.description' => ['required'],
        ];
    }

    /**
     * Get custom attributes for validator errors.
     *
     * @return array<string, string>
     */
    public function attributes(): array
    {
        return [
            'title' => 'タイトル',
            'author.name' => '著者名',
            'author.description' => '著者詳細',
        ];
    }

    /**
     * Get custom messages for validator errors.
     *
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [
            'title.required' => 'タイトルは必ず入力してください。',
        ];
    }
}
```

Laravelの標準バリデーションルールのエラーメッセージの多くは `:attribute` プレースホルダーを含みます。

`attributes()` メソッドをオーバーライドしてカスタム属性名を設定できます。
エラーメッセージそのものを変更したい場合は `messages()` メソッドをオーバーライドしてカスタマイズできます。

カスタムした属性名やメッセージは翻訳機能を利用すると表記揺れを防げるのでオススメです。

https://readouble.com/laravel/9.x/ja/localization.html

## バリデーション失敗時

FormRequestのバリデーションに失敗した時の動作についてです。

### HTTP

バリデーションに失敗すると `Illuminate\Validation\ValidationException` の例外が発生し、直前のページへリダイレクトします。
また、バリデーションエラーとリクエストの入力データはセッションに一時保存されています。

Laravel標準の `web` ミドルウェアグループの `Illuminate\View\Middleware\ShareErrorsFromSession` ミドルウェアによって `$errors` 変数がすべてのビューで利用可能です。

`$errors` 変数には `Illuminate\Support\MessageBag` インスタンスが入っています。

JetstreamのバリデーションエラーのBladeテンプレートです。

```php:resources/views/vendor/jetstream/components/validation-errors.blade.php
@if ($errors->any())
    <div {{ $attributes }}>
        <div class="font-medium text-red-600">{{ __('Whoops! Something went wrong.') }}</div>

        <ul class="mt-3 list-disc list-inside text-sm text-red-600">
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif
```

https://github.com/laravel/jetstream/blob/2.x/resources/views/components/validation-errors.blade.php

#### 補足: リダイレクト処理のカスタマイズ

リダイレクトの処理の指定がない場合は一つ前のページにリダイレクトバックします。
リダイレクト処理をカスタムしたい場合は、[`FormRequest`](https://github.com/laravel/framework/blob/9.x/src/Illuminate/Foundation/Http/FormRequest.php) クラスの `getRedirectUrl()` をオーバーライドしてください。

```php:vendor/laravel/framework/src/Illuminate/Foundation/Http/FormRequest.php
class FormRequest extends Request implements ValidatesWhenResolved
{
    /**
     * Get the URL to redirect to on a validation error.
     *
     * @return string
     */
    protected function getRedirectUrl()
    {
        $url = $this->redirector->getUrlGenerator();

        if ($this->redirect) {
            return $url->to($this->redirect);
        } elseif ($this->redirectRoute) {
            return $url->route($this->redirectRoute);
        } elseif ($this->redirectAction) {
            return $url->action($this->redirectAction);
        }

        return $url->previous();
    }
}
```

下記のプロパティを上書きするとリダイレクト先を変更できるわけですね。

```php:app/Http/Requests/ArticleStoreRequest.php
final class ArticleStoreRequest extends FormRequest
{
    // protected $redirect = '/'; // パス
    // protected $redirectRoute = 'welcome'; // 名前付きルート
    // protected $redirectAction = 'WelcomeController@welcome'; // コントローラ

    // ...
}
```

使うことないかも...トリビア〜😇

### JSON

Laravelはリクエストヘッダに `Accept: application/json` が付いている場合に`Illuminate\Validation\ValidationException` の例外が発生が失敗するとが発生するとJSONレスポンスを返却します。

```json
{
    "message": "The team name must be a string. (and 4 more errors)",
    "errors": {
        "team_name": [
            "The team name must be a string.",
            "The team name must be at least 1 characters."
        ],
        "authorization.role": [
            "The selected authorization.role is invalid."
        ],
        "users.0.email": [
            "The users.0.email field is required."
        ],
        "users.2.email": [
            "The users.2.email must be a valid email address."
        ]
    }
}
```

https://readouble.com/laravel/9.x/ja/validation.html#validation-error-response-format

## さいごに

ルーティングの解説記事を書いてから全機能書いてやるぞと意気込んだもののお蔵入りになっていました笑
アドベントカレンダーの季節になったこともあり重い腰をあげてなんとか書き上げました！