🏃️

LaravelのクラスをPHPStanで拡張して使いやすく安全なクラスを作る

2023/12/17に公開

この記事は Laravel Advent Calendar 2023 18日目の記事です。
https://qiita.com/advent-calendar/2023/laravel

Laravelを使っててつらみを感じる時

Laravelは柔軟性と機能の豊富さを持った素敵なフレームワークですが、Laravelが提供しているメソッドが便利すぎるが故に戻ってくる値の型がわからず、実際に処理やテストを実行するまでバグに気付けない時があります。

例えばIlluminate\Foundation\Http\FormRequestを継承したBarRequestクラスがあるとします

BarRequest.php
class BarRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
     */
    public function rules(): array
    {
        return [
            'hoge' => ['integer'],
            'piyo' => ['string'],
            'fuga' => ['integer', 'in:5|6|7']
        ];
    }
}

このリクエストクラスからバリデーションした値を取り出すと以下のような問題が発生します

BarController.php
class BarController extends Controller
{
    public function __invoke(BarRequest $request): void
    {
        // hugaというkeyは存在しないのにnullが帰ってくるのでタイポに気付けない
        $fuga = $request->validated('huga');

        // $piyoがnullになり得るのに、実行しないと気づくことができない
        $piyo = $request->validated('piyo');
        $this->useStringValue($piyo);

        // hogeとpiyoは型が違うが、実行しないと気づくことができない
        $hoge = $request->validated('hoge');
        $this->useStringValue($hoge);
    }

    private function useStringValue(string $piyo): void
    {
        //
    }
}

これらの問題は処理を実行するまで気づくことができず、テストに漏れがあった場合はバグに繋がります。
今回はこれらの問題をPHPStanを使って実行前の段階で気付けるようにしていこうと思います。

環境

  • Laravel: 10.11.0
  • larastan: 2.6.0(PHPStan1.6以上を使えれば今回はOKです)
  • PHPStanのレベル: 8(後述しますが、レベル8にすることでnullのチェックができます)

実装

完成系だけみたい人はこちら

達成したい要件

今回は例にあげたバリデーションクラスを拡張して以下の要件を達成していきます

  1. rules()配列のキーに存在する文字列 or nullのみvalidated()の第一引数に渡されるようにする
  2. validated()の引数によって変わる戻り値の型を明確にする

準備

今回はvalidated()メソッドのみ拡張したいのでIlluminate\Foundation\Http\FormRequestを継承したクラスを作成し、その中にvalidatedメソッドを作成します。
validated()内で処理の内容は変えないので親クラスの処理をそのまま呼び出します。

BaseRequest.php
abstract class BaseRequest extends FormRequest
{    
    public function validated($key = null, $default = null)
    {
        return parent::validated($key, $default);
    }
}

このクラスを先ほどのBarRequestクラスで継承します

BarRequest.php
- class BarRequest extends FormRequest
+ class BarRequest extends BaseRequest

これで拡張の事前準備ができました。ここからこれらのクラスに変更を加えていきます。

1. rules()配列のキーに存在する文字列 or nullのみvalidated()の第一引数に渡されるようにする

これを実現するにはどういう配列が渡されているかをPHPStanに伝える必要があります。
そのためにPHPStanのジェネリクスを使います

BaseRequest.php
+ /**
+  * @template T of array<string, mixed>
+  */
abstract class BaseRequest extends FormRequest

ジェネリクスを利用することでTに任意の型を渡すことができます。
次にBarRequestクラスでBaseRequestクラスで定義したジェネリクスTに今回利用する配列の型を渡します。

BarRequest.php
+ /**
+  * @extends BaseRequest<array{
+  *     hoge?: int,
+  *     piyo?: string,
+  *     fuga?: 5|6|7
+  * }>
+  */
abstract class BaseRequest extends FormRequest

これでBaseRequestrules()で使用される配列を伝えることができました。
次にBaseRequest::validated()の第一引数の$keyにとりうる値を制限します

BaseRequest.php
+ /**
+  * @param key-of<T>|null $key
+  */
public function validated($key = null, $default = null)
{
    return parent::validated($key, $default);
}

これで$keyにはTに渡された配列のキーの文字列であるhoge, piyo, fugaかデフォルトのnullのみに制限することができました。
試しにBarControllerクラスで$keyをタイポした時にどうなるか試してみましょう

BarController.php
class BarController extends Controller
{
    public function __invoke(BarRequest $request): void
    {
        $request->validated();  // OK

        $request->validated('hoge');  // OK

        $request->validated('huga');  // Parameter #1 $key of method {{クラス名}}::validated() expects 'fuga'|'hoge'|'piyo'|null, 'huga' given.
    }
}

配列のキー以外の文字列が渡された時にPHPStanでエラーを出すことができましたね👏
これで「rules()配列のキーに存在する文字列 or nullのみvalidated()の第一引数に渡されるようにする」を達成することができました

2. validated()の引数によって変わる戻り値の型を明確にする

次にvalidated()の引数によって変わる戻り値の型をPHPStanに伝えられるようにします。
これを達成するためには以下の条件によって型が変わるということをPHPStanに伝える必要があります

条件が複雑なので1ずつ対処していきましょう

$keyが指定されてない時はTの型

これはシンプルでreturnの型にTを指定してあげるだけです。

BaseRequest.php
/**
 * @param key-of<T>|null $key
+ * @return T
 */
public function validated($key = null, $default = null)

これで$keyに指定がない時はTに渡された配列の型が戻るように指定することができました。

$keyが指定してあるときはT[$key]の型かnull

まず、T配列の$keyの値の型を取得するにはPHPStanのOffset accessを使います。
通常の配列のように配列の中から指定キーの値の型を取得できます。

BaseRequest.php
/**
 * @param key-of<T>|null $key
- * @return T
+ * @return T[key-of<T>]|null
 */
public function validated($key = null, $default = null)

これで$keyが指定されたときの戻り値の型を指定できましたが、$keyが指定されていない時にTが戻ることを指定できなくなってしまいました。
これを解決するためにPHPStanのConditional return typeを使って型による条件分岐を行います。
Conditional return typeを利用することで型による条件分岐を三項演算のような形で実現することができます。
さらにkey-of<T>が何回も出るようになったのでkey-of<T>Kというテンプレートに置き換えます

BaseRequest.php
/**
- * @param key-of<T>|null $key
- * @return T[key-of<T>]
+ * @template K of key-of<T>
+ * @param K|null $key
+ * @return ($key is null ? T : T[K]|null)
 */
public function validated($key = null, $default = null)

これで$keyが指定されていない時はT, $keyが指定されている時はT[$key]の型を戻り値として指定することができました。
ここまでをまた、BarControllerで試してみましょう

BarController.php
class BarController extends Controller
{
    public function __invoke(BarRequest $request): void
    {
        $all = $request->validated();
        $this->useStringOrNull($all['piyo'] ?? null); // OK

        $piyo = $request->validated('piyo');
        $this->useStringOrNull($piyo); // OK

        $hoge = $request->validated('hoge');
        $this->useStringOrNull($hoge); //Parameter #1 $piyo of method {{クラス名}}::useStringValue() expects string|null, int|null given.
    }

    private function useStringOrNull(?string $piyo): void
    {
        //
    }
}

piyoが渡された時にはstring|nullが、hogeが渡された時にはint|nullが戻ってくることを確認できました。

$defaultが指定されている時はT配列の$keyの値の型か$defaultの型

次に$defaultが渡されている時のケースを考えます。
筆者は$defaultを指定するときはT[K]と型を揃えるようにしているので今回もその方針でいきます。
$defaultの型はT[K]かnullになるのでそれを指定してあげます。

BaseRequest.php
/**
 * @template K of key-of<T>
 * @param K|null $key
+ * @param T[K]|null $default
 * @return ($key is null ? T : T[K]|null)
 */
public function validated($key = null, $default = null)

これで$defaultにはT[K]と違う値は指定できなくなりました

BarController.php
class BarController extends Controller
{
    public function __invoke(BarRequest $request): void
    {
        $request->validated('hoge', 0);  // OK

        $request->validated('hoge', 'default hoge');  // Parameter #2 $default of method {{クラス名}}::validated() expects int|null, string given
    }
}

次に$defaultによって変わる戻り値の型を再度Conditional return typeを使って条件分岐していきます

BaseRequest.php
/**
 * @template K of key-of<T>
 * @param K|null $key
 * @param T[K]|null $default
- * @return ($key is null ? T : T[K]|null)
+ * @return ($key is null ? T : $default is null ? T[K]|null : T[K])
 */
public function validated($key = null, $default = null)

これで$keyが指定されていない時はT,
$keyが指定されている時はT[K]|null,
$defaultが指定されている時はT[K]を戻り値として指定できるようになりました。

完成系

完成したBaseRequest.phpが以下になります

/**
 * @template T of array<string, mixed>
 */
abstract class BaseRequest extends FormRequest
{
    /**
     * @template K of key-of<T>
     * @param K|null $key
     * @param T[K]|null $default
     * @return ($key is null ? T : $default is null ? T[K]|null : T[K])
     */
    public function validated($key = null, $default = null)
    {
        return parent::validated($key, $default);
    }
}

最後にこれを一番最初にあげたBarControllerで確認してみましょう

BarController.php
class BarController extends Controller
{
    public function __invoke(BarRequest $request): void
    {
        // keyをタイポしている
        $fuga = $request->validated('huga');  // Parameter #1 $key of method {{クラス名}}::validated() expects 'fuga'|'hoge'|'piyo'|null, 'huga' given.

        // 戻り値がnullになりうる
        $piyo = $request->validated('piyo');
        $this->useStringValue($piyo); // Parameter #1 $piyo of method {{クラス名}}::useStringValue() expects string, string|null given.

        // $hogeの型はint|null
        $hoge = $request->validated('hoge');
        $this->useStringValue($hoge);  // Parameter #1 $piyo of method App\Http\Controllers\Generics\BarController::useStringValue() expects string, int|null given.
        
        // $piyo2の型はstring
        $piyo2 = $request->validated('piyo', 'default');
        $this->useStringValue($piyo2);  // OK
    }

    private function useStringValue(string $piyo): void
    {
        //
    }
}

これで見事戻り値の厳密な型チェックを行い、最後のパターンのみエラーが出ないようにすることができました👏

まとめ

近年goやTypeScriptなど静的型付け言語が流行しています。
動的型付け言語であるPHPも言語レベルでのサポートはまだまだなものの、PHPStanを使えばgoやTypeScriptには敵わずとも、最低限のことはできるようになっていると思います。
今後のプログラミングはいかに静的解析と一緒に戦えるかだと思っているので引き続き機能を増やして欲しいですね

GitHubで編集を提案

Discussion