Produced by FOURIER

LaravelのActionと使い方

HirayamaHirayama calender 2022.7.27

Laravel 8くらいから、Laravelの公式パッケージであるFortifyをインストールした際、Actionsディレクトリが生成されるようになりました。(Laravel Actionsではないです)

Laravel公式ドキュメントにも載っていないこの謎のActionの役割や使われ方をLaravelのMVCやその問題点を絡めて説明し、自分でActionを作るときの考え方について解説します。

Laravelのアーキテクチャについて

まずは、Laravelのアーキテクチャについて軽く説明します。

LaravelはMVCパターンと呼ばれるアーキテクチャを採用しており、Model、View、Controllerに処理を分離し、ModelにDBの操作やデータの格納、Viewに画面表示の制御、Controllerにサーバー内部の処理を記述します。

MVCの問題点とService層の追加

単純なアプリケーションであればMVCでも問題ないのですが、複雑なアプリケーションになると、ModelやControllerに大量の処理が記述されたり、共通化されてない部分が出てきてくるため、保守性や品質の担保が難しくなってきます。

そこでよく取られる解決策として、Service層の追加があります。

複数のControllerに記述されるような処理をService層のクラスとして定義し、共通化することで上記の問題点を解決することができます。

Actionについて

前段としてMVCの問題点とよく取られる解決策を解説しましたが、FortifyではActionの追加という若干異なるアプローチでMVCの問題点を解決しており、その実装方法にも工夫があります。

工夫1: Contractの使用

Fortifyではリンク先のファイル一覧のように、全てのActionにContractファイルを用意しています。

fortify/src/Contracts at 1.x · laravel/fortify · GitHub

Backend controllers and scaffolding for Laravel authentication. - fortify/src/Contracts at 1.x · laravel/fortify

https://github.com/laravel/fortify/tree/1.x/src/Contracts

また、FortifyServiceProviderにて、Contractと実装を結合しています。

use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use App\Http\Responses\LoginResponse;
use App\Http\Responses\RegisterResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Fortify\Fortify;

class FortifyServiceProvider extends ServiceProvider
{
    public function register() { }

    public function boot()
    {
        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);

        Fortify::viewPrefix('auth.');

        RateLimiter::for('login', function (Request $request) {
            $email = (string) $request->email;

            return Limit::perMinute(5)->by($email.$request->ip());
        });

        RateLimiter::for('two-factor', function (Request $request) {
            return Limit::perMinute(5)->by($request->session()->get('login.id'));
        });

        $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
        $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class);
    }
}

そして、Actionを使用するときは、ControllerのメソッドにDIして使用しています。

fortify/RegisteredUserController.php at 9dee9cf87c6469af1c3ab001e5a83c080efa306c · laravel/fortify · GitHub

Backend controllers and scaffolding for Laravel authentication. - fortify/RegisteredUserController.php at 9dee9cf87c6469af1c3ab001e5a83c080efa306c · laravel/fortify

https://github.com/laravel/fortify/blob/9dee9cf87c6469af1c3ab001e5a83c080efa306c/src/Http/Controllers/RegisteredUserController.php#L51

こうすることによって、基本的な処理の流れをライブラリのControllerに記述しつつも、利用者がActionを書き換えることで、処理の内容を自由にカスタマイズできるようになっています。

工夫2: インターフェースに依存しない設計

FortifyのContractを見ると、いずれもarray等の単純な型のデータを受け取り、Responsiableインターフェースを実装したオブジェクトを返すようになっています。

特定のControllerに依存する(Requestを引数に受け取る)ようになっていないため、どのControllerから呼び出し可能なように作られています。

自分でActionを作る場合

自分で作る場合は前節で説明した工夫点を踏襲するように作れば良いとは思いますが、FortifyのActionはライブラリとして作られているため、アプリケーションを作るときとは視点が異なります。

そこで、前節の工夫点も交えながら、自分で作る場合に気をつけるポイントを以下のように考えてみました。

ポイント1: Contractは作らない

ライブラリとして作る際はContractを作ることで利用者の実装の自由度を高められますが、アプリケーションを作成しているときは実装を一つしか作らない場合がほとんどです。そのため、Contractを作っても1つしか継承するクラスが無いため、無駄に時間を食うだけの存在になってしまいます。

LaravelのService Containerは設定なしの依存解決を行ってくれるので、Contractを作らず、Service Containerも登録せずに、いきなりControllerのメソッドにDIしても問題ありません。

ポイント2: なるべく小さく作る

Actionの汎用性を高めるため、なるべく一つの物事を実行するように作ったほうがいいです。

例えば、あるエンドポイントではA→B→Cの順に処理を実行するのであれば、A、B、CそれぞれをActionとして作ることで、後々A’→B→Cといった処理を別のエンドポイントで実装する必要が出てきても、A’を作るだけで簡単に対応できるようになります。

一連の処理をActionに切り分けるやり方には正解はなく、区切りすぎてもわかりにくくなったりするので判断が難しいところですが、最初はある程度まとまった処理をAction(≒Service)として作り、必要に応じて段々と分けていくやり方が無難かなと思います。

ポイント3: 疎結合に作る

FortifyのActionは特定のControllerに依存しないように作られていましたが、もう一歩進んでActionの戻り値もプリミティブな型かEloquent Modelにすることで、以下のようなメリットが生まれます。

メリット1: Actionを連続して実行できる

これはWebエンドポイントとAPIエンドポイントを両方実装するケースを考えてみた場合、APIエンドポイントで使っているActionをWebエンドポイントでも使いたくなると思います。

Webエンドポイントでは複数のAPIエンドポイントに相当するような処理を一つのエンドポイントで処理しなければいけない場面も多々あるため、ActionがResponsibleを返すと複数のActionを実行した際のデータの結合がやりづらくなります。

そのため、Actionはデータを返し、Controllerで自由にレスポンスを作ることができるようにすることで、より汎用性を高めることができます。

メリット2: 完全にインターフェースから分離できる

FortifyのActionはResponsibleを返す都合上、API・Webエンドポイントでの利用を想定しています。

認証処理なのでその実装で問題ありませんが、Actionがデータを返却するようにすることで、ArtisanコンソールやEvent Listener等から呼び出すことも可能になるため、インターフェースに依存しない再利用しやすいActionになります。

具体例

以下に、自分がActionを実際に実装したときのコードを具体例として載せます。

Index Action

このActionは、Postモデルの一覧をページネーションとして取得する処理をします。

namespace App\Actions\Post;

use App\Models\Post;
use Illuminate\Contracts\Pagination\Paginator;

class IndexAction
{
    public function __invoke(
        ?int    $current_page,
        ?int    $per_page,
        ?string $sort_by,
        ?string $direction,
    ): Paginator
    {
        $current_page ??= 1;
        $per_page     ??= 20;
        $sort_by      ??= 'id';
        $direction    ??= 'asc';

        return Post::query()
                   ->orderBy($sort_by, $direction)
                   ->paginate($per_page, page: $current_page);
    }
}

Store Action

このActionでは、Postモデルを新規保存する処理をします。

保存直後にEventを発生させることで、このStoreに付随して発生する処理を実行させることができます。

<?php

namespace App\Actions\Post;

use App\Events\Post\PostStored;
use App\Models\post;

class StoreAction
{
    public function __invoke(User $user, string $body): Post
    {
        $post = new Post(compact('body'));

                $user->posts()->save($post);
        $post->refresh();

        event(new PostStored($post));

        return $opinion;
    }
}

まとめ

本記事ではActionについてどういったものなのか、どういうふうに実装すれば良いのかを解説しました。

公式ドキュメントにも載ってないので、絶対にActionを書かなければいけないという代物ではないですが、公式ライブラリの書き方に合わせたほうが何かと都合がいいと思うので、私は今後Laravelで実装するときは、Serviceではなく、Actionで実装していこうかなと思いました。

新しいメンバーを募集しています

Hirayama

Hirayama / Engineer

1997年生まれ、南伊豆出身。学生時代にC#で画像処理アプリケーションを作ったりしていました。業務では主にLaravelを使用してサーバーサイドのプログラミングをしています。趣味はドライブとシミュレーションゲーム。