🔖

LaravelでHttpRequestを扱う際の個人的ベストプラクティス

2022/08/08に公開

最終的な成果物

基本的な考え方と、問題点

LarvelにてRequestを扱う際、少しググった場合も、書籍等の場合も大体FormRequestを継承すると便利だよー。
といった旨の解説とともに、Sampleでコードを載せてくれています。
そのSampleはとてもSimpleで確かにこれは簡単だ!と思わせてくれる内容であるので大抵はそのSampleに習い実装をしていくことかと思います。

しかし、時と場合によっては複雑なHttpRequestを扱わなければいけなかったり、とても大量のパラメータを扱わなければいけなかったりします。
そのような状態に陥ったときに、

FormRequestを継承して使う。

だけでは少々苦しい場面に遭遇することがあります。
苦しいとはいっても

  • RequestクラスがFatになる
  • バリデーションの記述が少し難解になる

くらいではあるのですが。
また、これはそもそもですがLaravelのFormRequestは

  • どのようなパラメータを受け付けるかについての見通しがよくない

とも思ってます。

そのうえ、コントローラ側では、連想配列でパラメータ名を指定して取得するやり方が大々的に紹介されております。
これではIDEによるコード補完を受けることができません。
また保障されていないパラメータを記載できてしまうので書いていて少し怖いですよね。

これを少しでも快適にしたかった私は、
FormRequestの機能を継承しつつ
さらにカスタマイズした自分なりのHttpRequest処理を組んでみました

問題点解決にむけて

上記問題点から、まずは

①Requestパラメータを定義するクラス
②Requestを処理するクラス

といった具合に責務を大まかに二分させ、②に①を注入する形にて解決を試みました。

①Requestパラメータを定義するクラス

ここで解決させたいことは

  • バリデーションの記述が少し難解になる
  • どのようなパラメータを受け付けるかについての見通しがよくない

と絞り対策をしていきます。

上記を解決するために必要な機能としては

  • バリデーションルールを組み立てる関数
  • バリデーションルール項目を組み立てる関数

が必須になってきます。またこれは必須とはいえませんが

  • Requestパラメータを加工することができる関数
    も用意しておきます。※これに関してはいらな、気もしていますが、、迷った挙句つけておきました

上記関数実装を強要するinterfaceを作成します

DefinitionInterface.php
<?php

namespace App\Http\Requests\Definition\Basic;

interface DefinitionInterface
{
    public function buildValidateRules();

    public function transform(array $attrs);

    public function buildAttribute();
}

バリデーションの記載を完結にしたい。

  • Laravelのバリデーションの記載の仕方自体に不満はなく、同じように書きたいがもっと見通しをよくしたい。
  • パラメータ全体の見通しをよくしたい
    例えばhogeというパラメータを受け付けるような場合下記のような記述で簡潔させることを目指します。
SampleDefinition
class SampleDefinition 
{
    /**
     * HttpRequestParameter
     * @var string
     */
    //HOGE
    protected string $hoge = 'required|string';
}

先ほどのinterfaceでは3つの関数を実装する必要があります、上記のような簡潔さを実現するために
3つの関数を実装した基底クラスを用意しました。

AbstractRequestDefinition.php
<?php

namespace App\Http\Requests\Definition\Basic;

abstract class AbstractRequestDefinition
{
    protected array $rules = array();
    protected array $attribute = array();
    
    /**
     * FormRequestにて読み込めるルールの形に整形
     */
    public function buildValidateRules(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {
                $this->rules[$key] = $val;
            }
        }
        return $this->rules;
    }

    /**
     * FormRequestにて読み込めるルール項目名の形に整形
     */
    public function buildAttribute(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {
               $this->attribute[$key] = __('attributes.' . $key);
            }
        }
        return $this->attribute;
    }

    /**
     * プロパティ間の連結など加工したいパラメータを設定
     * example $attrs['tel'] = implode('', $attrs['tel']);
     * @param array $attrs
     * @return array
     */
    public function transform(array $attrs): array
    {
        return $attrs;
    }
}

先ほどのSampleDefinitionに上記基底クラスを継承させ、interfaceを指定しました。

SampleDefinition
class SampleDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    //HOGE
    protected string $hoge = 'required|string';
}

②Requestを処理するクラス

ここまでで作成したクラスたちはパラメータ、バリデーション定義のみを記載したクラスであり、これらを有効活用するための機能を実装した
Request処理の基底クラスを実装していきます。

AbstractFormRequest.php

    abstract protected function transform(array $attrs);
    
    /**
     * Symfony\Component\HttpFoundation\Requestのconstructを上書ますが、
     * LaravelではIlluminate\Http\RequestないcreateFromにてinitializeを実施してくれています。
     * ここではDefinitionをDIするように変更します
     */
    public function __construct(DefinitionInterface $definition = null)
    {
        $this->definition = $definition;
    }

    /**
     * HTTPリクエストプロパティを配列で返却します。
     * '_' で始まるパラメータは除外されます。
     * definition 側で加工定義があるものは加工されて返却されます。
     */
    public function attrs()
    {
        $attrs = array_filter($this->all(), function ($k) {
            return !str_starts_with($k, '_');
        }, ARRAY_FILTER_USE_KEY);

        if ($this->definition === null) {
            return $this->transform($attrs);
        }
        return $this->definition->transform($attrs);
    }

    /**
     * requestでは認証回りは扱わない
     * @return bool
     */
    final public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return $this->definition->buildValidateRules();
    }

    /**
     * 項目を和名で返却します。
     * @return array
     */
    public function attributes(): array
    {
        return $this->definition->buildAttribute();
    }

    /**
     * リクエストパラメータとURIの{id}部分を取得して
     * バリデーションの範囲に追加します。
     *
     * @return array $request リクエスト
     */
    public function validationData(): array
    {
        $request = parent::validationData();
        $request['id'] = $this->route('id');

        return $request;
    }

    /**
     * バリデーションのルールに沿わなかった場合に呼び出されるメソッド
     * エラーメッセージを配列で返却します。
     * TODO Exception要
     * @throws ValidationException
     */
    protected function failedValidation(Validator $validator)
    {
        $this->validationMessage = $validator->errors()->toArray();
        logger($validator->errors()->toJson());
        throw new ValidationException('V_0000000', $this->validationMessage);
    }

    public function messages(): array
    {
        return $this->validationMessage;
    }

この基底クラスを継承することで、基本的な部分に関しては下記のように簡潔に記載できるようにしました

<?php

namespace App\Http\Requests\Sample;

use App\Http\Requests\Basic\AbstractFormRequest;
use App\Http\Requests\Definition\Sample\SampleDefinition;

class SampleRequest extends AbstractFormRequest
{
    /**
     * 依存するDefinitionを注入させる
    */
    public function __construct(SampleDefinition $definition = null)
    {
        parent::__construct($definition);
    }

    protected function transform(array $attrs): array
    {
        return $attrs;
    }

    public function getHoge()
    {
        return $this->hoge;
    }
}

このようにすることで

  • RequestクラスがFatになる
    をできる限り解消させてみました。

複雑なリクエストへ対応

しかし、実はこれだけではまだ考慮がたりていません。
複雑なHttpRequestとして考えられるものとしては

  • パラメータにオブジェクトが含まれる場合、配列であり中身がオブジェクトの場合。

のようなパターンに対して致命的に考慮が足りていません。
「これはオブジェクト定義が複雑である。」という問題とも言い換えられます。
つまり
①Requestパラメータを定義するクラス
にて解決したいですね。

先の例にて、SampleDefinitionというクラス内のメンバ変数にパラメータ項目名の定義とバリデーションルール定義を記載しておりましたが、
この記述はAbstractRequestDefinitionによって読み取られ、実際のバリデーションルールへと加工していく仕組になっているのを利用します。

まず規定側に

AbstractRequestDefinition.php

    /**
     * 入れ子のオブジェクトがある場合下記関数に追記される。
     * @return array
     */
    public function childDefinition(): array
    {
        return [];
    }

を用意し、実装側では

SampleDefinition.php
    
    protected string $hoge = 'required|string';
    //オブジェクトはobjectというルールで表現してみる
    protected string $hogeObject = 'required|object';
    //配列内オブジェクトはcollectionObjectというルールで表現
    protected string $fugaObjectList = 'required|collectionObject';

    /** オーバーライド */
    public function childDefinition(): array
    {
        return [
            'hogeObject' => new HogeObjectDefinition(),
            'fugaObjectList' => [new FugaObjectListDefinition()]
        ];
    }
HogeObjectDefinition.php
    
<?php

namespace App\Http\Requests\Definition\Sample;

use App\Http\Requests\Definition\Basic\DefinitionInterface;
use App\Http\Requests\Definition\Basic\AbstractRequestDefinition;

class HogeObjectDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    protected string $hogeName = 'required';
}

FugaObjectListDefinition.php
    
<?php

namespace App\Http\Requests\Definition\Sample;

use App\Http\Requests\Definition\Basic\DefinitionInterface;
use App\Http\Requests\Definition\Basic\AbstractRequestDefinition;

class FugaObjectListDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    protected string $fuga = 'required';
}

のようにパラメータ構造により、定義ファイルも同様の構造化をすることで解決を図りました
HogeObjectDefinitionや、FugaObjectListDefinitionにオブジェクト等があった場合も、再帰的にバリデーションルールを生成することを目的として
基底クラスにさらに下記改修を実施していきます。

AbstractRequestDefinition.php
    public function buildValidateRules(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {
                if (str_contains($val, 'collectionObject')) {
                    //配列内オブジェクトがルールに指定されている場合。
                    $this->rules[$key] = str_contains($val, 'required') ? 'required|array' : 'array';
                    //childDefinitionに定義されている配列(中身はオブジェクト)のうち0個目を呼び出す
                    //TODO リテラル撤廃する
                    $children = $this->childDefinition();
                    $child_rules = $children[$key][0]->buildValidateRules();
                    //階層になっているオブジェクトも再帰的にルールを整形していく
                    foreach ($child_rules as $child_key => $child_rule) {
                        $this->rules[$key . '.*.' . $child_key] = $child_rule;
                    }
                } elseif(str_contains($val, 'object')){
                    $children = $this->childDefinition();
                    $child_rules = $children[$key]->buildValidateRules();
                    //階層になっているオブジェクトも再帰的にルールを整形していく
                    foreach ($child_rules as $child_key => $child_rule) {
                        $this->rules[$key . '.' . $child_key] = $child_rule;
                    }
                }
                else {
                    $this->rules[$key] = $val;
                }

            }
        }

        return $this->rules;
    }

    public function buildAttribute(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {
               if (str_contains($val, 'collectionObject')) {
                   //配列内オブジェクトがルールに指定されている場合。
                   $children = $this->childDefinition();
                   //childDefinitionに定義されている配列(中身はオブジェクト)のうち0個目を呼び出す
                    //TODO リテラル撤廃する
                   $child_attrs = $children[$key][0]->buildAttribute();
                   //階層になっているオブジェクトも再帰的にルール項目名を整形していく
                   foreach ($child_attrs as $child_key => $child_attr) {
                       $this->attribute[$key . '.*.' . $child_key] = $child_attr;
                   }
               } elseif(str_contains($val, 'object')) {
                   $children = $this->childDefinition();
                   $child_attrs = $children[$key]->buildAttribute();
                   //階層になっているオブジェクトも再帰的にルール項目名を整形していく
                   foreach ($child_attrs as $child_key => $child_attr) {
                       $this->attribute[$key . '.' . $child_key] = $child_attr;
                   }
               }
               else {
                   $this->attribute[$key] = __('attributes.' . $key);
               }
            }
        }
        return $this->attribute;
    }

このようにすることで、階層的なHttpRequestパラメータにも実装クラス側はそこそこすっきりと対応させることができました。

最終成果物

最終的なDefinition/Requestのinterface及び基底クラス

DefinitionInterface.php
<?php

namespace App\Http\Requests\Definition\Basic;

interface DefinitionInterface
{
    public function buildValidateRules();

    public function transform(array $attrs);

    public function buildAttribute();
}
AbstractRequestDefinition.php
<?php

namespace App\Http\Requests\Definition\Basic;

abstract class AbstractRequestDefinition
{
    protected array $rules = array();
    protected array $attribute = array();

    public function buildValidateRules(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {

                if (str_contains($val, 'collectionObject')) {
                    $this->rules[$key] = str_contains($val, 'required') ? 'required|array' : 'array';
                    $children = $this->childDefinition();
                    $child_rules = $children[$key][0]->buildValidateRules();
                    foreach ($child_rules as $child_key => $child_rule) {
                        $this->rules[$key . '.*.' . $child_key] = $child_rule;
                    }
                } elseif(str_contains($val, 'object')){
                    $children = $this->childDefinition();
                    $child_rules = $children[$key]->buildValidateRules();
                    foreach ($child_rules as $child_key => $child_rule) {
                        $this->rules[$key . '.' . $child_key] = $child_rule;
                    }
                }
                else {
                    $this->rules[$key] = $val;
                }

            }
        }

        return $this->rules;
    }

    public function buildAttribute(): array
    {
        foreach ($this as $key => $val) {
            if (!(empty($val) || $key == 'rules' || $key == 'attribute')) {
               if (str_contains($val, 'collectionObject')) {
                   $children = $this->childDefinition();
                   $child_attrs = $children[$key][0]->buildAttribute();
                   foreach ($child_attrs as $child_key => $child_attr) {
                       $this->attribute[$key . '.*.' . $child_key] = $child_attr;
                   }
               } elseif(str_contains($val, 'object')) {
                   $children = $this->childDefinition();
                   $child_attrs = $children[$key]->buildAttribute();
                   foreach ($child_attrs as $child_key => $child_attr) {
                       $this->attribute[$key . '.' . $child_key] = $child_attr;
                   }
               }
               else {
                   $this->attribute[$key] = __('attributes.' . $key);
               }
            }
        }
        return $this->attribute;
    }

    /**
     * プロパティ間の連結など加工したいパラメータを設定
     * @param array $attrs
     * @return array
     */
    public function transform(array $attrs): array
    {
        //$attrs['tel'] = implode('', $attrs['tel']);
        return $attrs;
    }

    /**
     * 入れ子のオブジェクトがある場合下記関数に追記される。
     * @return array
     */
    public function childDefinition(): array
    {
        /*
        return [
            'customer'=> new CustomerDefinition(),
        ];
        */
        return [];
    }
}
AbstractFormRequest.php
<?php

namespace App\Http\Requests\Basic;

use App\Exceptions\ValidationException;
use App\Http\Requests\Definition\Basic\DefinitionInterface;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use packages\domain\model\authentication\Account;

abstract class AbstractFormRequest extends FormRequest
{
    public array $validationMessage = [];
    protected DefinitionInterface $definition;

    abstract protected function transform(array $attrs);

    /**
     * Symfony\Component\HttpFoundation\Requestのconstructを上書ますが、
     * LaravelではIlluminate\Http\RequestないcreateFromにてinitializeを実施してくれています。
     * ここではDefinitionをDIするように変更します
     */
    public function __construct(DefinitionInterface $definition = null)
    {
        $this->definition = $definition;
    }

    /**
     * HTTPリクエストプロパティを配列で返却します。
     * '_' で始まるパラメータは除外されます。
     * definition 側で加工定義があるものは加工されて返却されます。
     */
    public function attrs()
    {
        $attrs = array_filter($this->all(), function ($k) {
            return !str_starts_with($k, '_');
        }, ARRAY_FILTER_USE_KEY);

        if ($this->definition === null) {
            return $this->transform($attrs);
        }
        return $this->definition->transform($attrs);
    }

    /**
     * requestでは認証回りは扱わない
     * @return bool
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules(): array
    {
        return $this->definition->buildValidateRules();
    }

    /**
     * 項目を和名で返却します。
     * @return array
     */
    public function attributes(): array
    {
        return $this->definition->buildAttribute();
    }

    /**
     * リクエストパラメータとURIの{id}部分を取得して
     * バリデーションの範囲に追加します。
     *
     * @return array $request リクエスト
     */
    public function validationData(): array
    {
        $request = parent::validationData();
        $request['id'] = $this->route('id');

        return $request;
    }

    /**
     * バリデーションのルールに沿わなかった場合に呼び出されるメソッド
     * エラーメッセージを配列で返却します。
     * TODO Exception要
     */
    protected function failedValidation(Validator $validator)
    {
        $this->validationMessage = $validator->errors()->toArray();
        logger($validator->errors()->toJson());
        throw new ValidationException('V_0000000', $this->validationMessage);
    }

    public function messages(): array
    {
        return $this->validationMessage;
    }

    /**
     * @return Account
     */
    public function getAuthedUser(): Account
    {
        return Auth::user();
    }
}

実装側

上記をフレームワーク的に利用することで、実装工程では下記のようなものを作成していきます。

SampleDefinition.php
    
<?php

namespace App\Http\Requests\Definition\Sample;

use App\Http\Requests\Definition\Basic\DefinitionInterface;
use App\Http\Requests\Definition\Basic\AbstractRequestDefinition;

class SampleDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    protected string $hoge = 'required|string';
    //オブジェクトはobjectというルールで表現してみる
    protected string $hogeObject = 'required|object';
    //配列内オブジェクトはcollectionObjectというルールで表現
    protected string $fugaObjectList = 'required|collectionObject';

    /** オーバーライド */
    public function childDefinition(): array
    {
        return [
            'hogeObject' => new HogeObjectDefinition(),
            'fugaObjectList' => [new FugaObjectListDefinition()]
        ];
    }
}
HogeObjectDefinition.php
    
<?php

namespace App\Http\Requests\Definition\Sample;

use App\Http\Requests\Definition\Basic\DefinitionInterface;
use App\Http\Requests\Definition\Basic\AbstractRequestDefinition;

class HogeObjectDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    protected string $hogeName = 'required';
}

FugaObjectListDefinition.php
    
<?php

namespace App\Http\Requests\Definition\Sample;

use App\Http\Requests\Definition\Basic\DefinitionInterface;
use App\Http\Requests\Definition\Basic\AbstractRequestDefinition;

class FugaObjectListDefinition extends AbstractRequestDefinition implements DefinitionInterface
{
    /**
     * HttpRequestParameter
     * @var string
     */
    protected string $fuga = 'required';
}

SampleRequest.php
<?php

namespace App\Http\Requests\Sample;

use App\Http\Requests\Basic\AbstractFormRequest;
use App\Http\Requests\Definition\Sample\SampleDefinition;

class SampleRequest extends AbstractFormRequest
{
    public function __construct(SampleDefinition $definition = null)
    {
        parent::__construct($definition);
    }

    protected function transform(array $attrs): array
    {
        return $attrs;
    }

    public function getHoge()
    {
        return $this->hoge;
    }
}

結構すっきりできたのではないでしょうか。

SampleRequest.phpは、実際にはもう少し記述が多くなる想定ではあります。
getter的なものを用意してコントローラから先の処理で補完を利かしたりするのもいいかもしれませんし
ドメインモデルなどがある場合は、toDomainName()といった感じで、ドメインモデルへコンバートさせる関数を用意してもよいかと思います。
私としてはは後者を好んで採用します

新たな問題点

  • Laravel標準ではない独特さの出現、collectionObjectとかって命名がよい例ですが
  • オブジェクト指向にありがちですが、Definition定義作成などメンドクサイ作業の発生

確かに複雑なパラメータを有するHttpRequestには効果がありそうですが、果たしてそのようなHttpRequestは多いのであろうか。
単純なRequestの場合は過剰な設計ではないか。

と自分でも思いました。
が、作成すべきクラスファイル一個一個が単純で、規則的に記載できそうです
Webアプリ開発において、インターフェース設計は基本的やらないことはないはずで、
設計時swaggerを使い納品物としても同梱することはかなり一般的になってきている。
swaggerは中身はyml,jsonである。これは上手いこと活用すれば自動で生成できるんじゃ。。。。?と思い立ちました
aritsanにSwaggerCodeGenというなんともな命名のコマンドを自作し作業効率化を図っています

こちらはまた別記事に記載しようと思います。

最後に

少し長く、且つ文章が冗長だったり読みづらかったりになってしましたが
自分的なLaravelのHttpRequest処理のベストプラクティスについて
説明してみました。

ご指摘や、もっといい方法などなどあれば情報交換できればなと思います。

※今回説明したRequest処理は下記ソース内で実践されています。
https://github.com/yuichi-sano/multiple-laravel-prj

以上

insight

Discussion