2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Laravel] シングルアクションコントローラで責務を分割する

Last updated at Posted at 2023-11-01

導入

Laravelでルートに関する処理を記述する場合、以下のように操作する対象のオブジェクト毎にコントローラクラスを生成し、ルート毎にメソッドを作るのが一般的かと思います。

namespace App\Http\Controllers;
 
use App\Models\User;
use Illuminate\View\View;
 
class UserController extends Controller
{
    /**
     * Show the profile for a given user.
     */
    public function show(string $id): View
    {
        return view('user.profile', [
            'user' => User::findOrFail($id)
        ]);
    }
}

ルート定義は以下のようになります。

use App\Http\Controllers\UserController;
 
Route::get('/user/{id}', [UserController::class, 'show']);

ただ、この方法だとルートが増えるたびにコントローラのメソッドが増えていき、各ルート処理が複雑になるとコントローラのファイルがどんどん肥大化していきます。
また、クラス毎の責務も曖昧になり、コントローラのクラスがどんどん読みづらくなっていきます。

この問題に対する解決策の一つとして、今回はシングルアクションコントローラの使い方についてご紹介します。

基本的な使い方

シングルアクションコントローラとは、その名の通り、1つのクラス内で1つのルートのアクションのみを処理するコントローラのことです。

まずは以下のように __invoke メソッドを持ったコントローラを定義します。

namespace App\Http\Controllers;
 
class ProvisionServer extends Controller
{
    /**
     * Provision a new web server.
     */
    public function __invoke()
    {
        // ...
    }
}

次に、ルート定義ファイルでは以下のようにコントローラの完全修飾名を指定します。

use App\Http\Controllers\ProvisionServer;
 
Route::post('/server', ProvisionServer::class);

ルート定義でクラス名が指定されていて、そのクラスが__invokeメソッドを実装していれば、通常のコントローラと同様に__invokeメソッドが呼ばれて処理が実行されます。

ルートバインディングモデルの受け取りやサービスコンテナからの注入などは、__invokeメソッドで行います。

シングルアクションコントローラを整理したい

シングルアクションコントローラを使うと、ルート毎にクラスができるため、コントローラクラスがどんどん増えていきます。
すべて Controller の中に配置してたのではすごいことになるので、名前空間を用いて整理する必要が出てきます。

ここでは一例として、ルートと名前空間の切り方を同期させた考え方をご紹介します。

ユーザー登録

例えば、Userというモデルに対する一連の処理を実装するとします。
ユーザーを登録する一連の処理を実装した、UserCreateControllerを、シングルアクションコントローラとして実装します。

namespace App\Http\Controllers\User;
 
class UserCreateController extends Controller
{
    public function __invoke()
    {
        // ...
    }
}

注目はnamespaceです。App\Http\Controllers\Userと、Controllerの1階層下にUserという名前空間を切り、そこに配置しています。
Userに関する処理はこの名前空間にまとめましょう、ということです。

ルート定義は以下のようになります。

Route::prefix('/users')->group(function () {
    Route::post('/', UserCreateController::class);
});

Route::prefix('/users')->group(function () {})で囲むことで、この中で定義されたルートはすべて/usersで始まるパスである、というグループ化を行うことができます。

ユーザー詳細取得

次に、ユーザーの詳細を取得するルートについて考えます。パスは/users/:idになると思います。

同じようにコントローラから定義します。

namespace App\Http\Controllers\User;

class UserReadController extends Controller
{
    public function __invoke(User $user)
    {
        // ...
    }
}

実際にはUserResource等を返すことになると思いますが、ここでは省略しています。
namaspaceを見ていただければ分かる通り、ユーザー登録の処理と同じ名前空間に配置しています。

同様にルート定義も追加します。

Route::prefix('/users')->group(function () {
    Route::post('/', UserCreateController::class);
    Route::prefix('{user}')->group(function () {
        Route::get('/',, UserReadController::class);
    });
});

userモデルをルートバインディングする部分もグループ化してみました。
(これはケースバイケースかも)

ユーザーに関するその他のルート

他にもユーザー情報の更新や、削除、またはそれ以外の処理など、ユーザーに対する処理を行うコントローラは、すべてApp\Http\Controllers\User以下に配置していきます。

ディレクトリ構成とルート定義はこんな感じになります。

app
└ Http
 └ Controllers
  └ User
   └ UserCreateController.php
   └ UserDeleteController.php
   └ UserReadController.php
   └ UserUpdateController.php
Route::prefix('/users')->group(function () {
    Route::post('/', UserCreateController::class);
    Route::prefix('{user}')->group(function () {
        Route::get('/',, UserReadController::class);
        Route::patch('/',, UserUpdateController::class);
        Route::delete('/',, UserDeleteController::class);
    });
});

ユーザー以外のモデルに対する処理

ユーザー以外のモデルに関する処理も、それぞれ専用の名前空間に配置していきます。
基本的にはController直下にコントローラを配置しません。

例えばPostだったらApp\Http\Controllers\PostAccountだったらApp\Http\Controllers\Accountです。

イメージとしてはこうなります。

app
└ Http
 └ Controllers
  └ User
   └ UserCreateController.php
   └ UserDeleteController.php
   └ UserReadController.php
   └ UserUpdateController.php
  └ Post
   └ PostCreateController.php
   └ PostDeleteController.php
   └ PostReadController.php
   └ PostUpdateController.php
Route::prefix('/users')->group(function () {
    Route::post('/', UserCreateController::class);
    Route::prefix('{user}')->group(function () {
        Route::get('/',, UserReadController::class);
        Route::patch('/',, UserUpdateController::class);
        Route::delete('/',, UserDeleteController::class);
    });
});
Route::prefix('/posts')->group(function () {
    Route::post('/', PostCreateController::class);
    Route::prefix('{post}')->group(function () {
        Route::get('/',, PostReadController::class);
        Route::patch('/',, PostUpdateController::class);
        Route::delete('/',, PostDeleteController::class);
    });
});

ちょっと複雑なパターン

例えば、あるユーザーが投稿した全てのコメントを取得するために、以下のようなパスを定義したとします。

/users/:userId/comments/:commentId

その場合、App\Http\Controllers\User\Commentという形で、Userの下に更に名前空間を切ります。クラス名も階層に合わせて UserCommentCreateControllerなどとします。

app
└ Http
 └ Controllers
  └ User
   └ UserCreateController.php
   └ UserDeleteController.php
   └ UserReadController.php
   └ UserUpdateController.php
   └ Comment
    └ UserCommentCreateController.php
    └ UserCommentDeleteController.php
    └ UserCommentReadController.php
    └ UserCommentUpdateController.php

まあ、あまりこだわりすぎるとファイル名が長くなったりするので、この辺はケースバイケースなのかなーとは思います。

まとめ

シングルアクションコントローラには、__invoke以外にpublicなメソッドは定義しません。どこがエントリポイントなのか非常にわかりやすくなります。
また、1クラス1責務が明確になったことで、処理が複雑になってきた場合には、privateなメソッドに処理を分割するなどしてコードを整理することが容易になります。

他にも、1つのアクションでしか使わないサービスクラスのインジェクションを、コンストラクタに分けることができるというメリットもあります。
通常の方法だとメソッドの引数としてバインディングされたモデルやFormRequestなどと一緒にDIしなければならないため、DIするクラスが多いと見た目がガチャガチャしがちですが、シングルアクションコントローラなら、DIするクラスはコンストラクタに、バインディングしたモデル等は__invokeに分割することができ、見た目の上でもスッキリします。

なお、名前空間の切り方はあくまで私が採用している方法というだけなので、参考程度に御覧ください。
チームの規模や規約に沿って適切な方法を選択するのが良いかと思います。

2
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?