Laravel サービス層設計でController肥大化を防ぐ実務ポイント

Laravel サービス層 設計でController、Service層、UseCase層の責務分離を整理する図

Laravel サービス層 設計はControllerを薄くするだけでは足りない

Laravel サービス層 設計で迷う場面は、実務ではかなり多いです。Controllerに処理を書き続けると、役割が混ざります。入力検証、権限確認、業務判断、Eloquent操作が同じメソッドに集まります。さらに、外部API連携やレスポンス整形まで入りやすくなります。

結論から言うと、Service層やUseCase層は「Controllerを短くするため」だけに作りません。変更理由を分けるために作ります。そのため、HTTPの都合と業務処理の都合を混ぜないことが重要です。

この記事では、Laravel実務でController肥大化を防ぐ設計を整理します。FormRequest、Controller、UseCase、Serviceの責務分離を扱います。また、Eloquentとの分け方も見ます。さらに、テストしやすい設計やコードレビュー観点も解説します。

Controller肥大化は責務の混在から始まる

まず、Controller肥大化は行数だけの問題ではありません。1つのメソッドに複数の変更理由が混ざることが問題です。

例えば、注文作成APIを考えます。Controllerの中で入力値を取り出します。さらに、在庫確認、割引計算、注文保存、メール送信まで行う実装はよくあります。最初は速く書けます。しかし、仕様変更やテスト追加のたびにController全体を読む必要が出ます。

肥大化したControllerの典型例

public function store(Request $request)
{
    $data = $request->validate([
        'product_id' => ['required', 'integer'],
        'quantity' => ['required', 'integer', 'min:1'],
    ]);

    $product = Product::findOrFail($data['product_id']);

    if ($product->stock < $data['quantity']) {
        return response()->json(['message' => '在庫が不足しています'], 422);
    }

    $total = $product->price * $data['quantity'];

    $order = Order::create([
        'user_id' => $request->user()->id,
        'product_id' => $product->id,
        'quantity' => $data['quantity'],
        'total' => $total,
    ]);

    Mail::to($request->user())->send(new OrderCreatedMail($order));

    return response()->json(['id' => $order->id], 201);
}

レビューで見えにくくなる変更理由

このコードは、Laravelとして動くかもしれません。一方で、入力検証、在庫判定、金額計算が同居しています。さらに、永続化、通知、HTTPレスポンスも同じ場所にあります。そのため、レビューでは変更理由が見えにくくなります。

Laravel公式のControllersでは、Controllerの役割が説明されています。Controllerはリクエスト処理を整理する入口です。ただし、業務ロジックをすべて集める必要はありません。実務では、業務判断を別のクラスへ寄せる設計が保守しやすいです。

LaravelのService層とUseCase層をどう分けるか

次に、Service層とUseCase層の分け方です。Laravelには、標準のService層ディレクトリがあるわけではありません。そのため、チームで責務を決める必要があります。決めないと、Serviceという名前の何でも屋が増えます。

実務では、まずUseCase層を「1つの業務操作の流れ」として考えます。例えば、注文を作成する、会員情報を更新する、請求を確定する、といった単位です。一方で、Service層は再利用される業務部品として扱います。価格計算、在庫引当、通知文面生成などが候補です。

主な責務置かないほうがよいもの
ControllerHTTPリクエストの受け取り、認証ユーザー取得、レスポンス変換業務ルール、複数DB更新、外部連携の詳細
FormRequest入力形式の検証、簡単な認可DB更新、複雑な業務判断、メール送信
UseCase1つの業務操作の流れ、トランザクション境界HTTPレスポンス生成、Viewの都合
Service再利用される業務ロジック、計算、判定リクエスト依存の処理、何でも詰め込む処理
Eloquent Model永続化モデル、リレーション、スコープ画面ごとの処理フロー、大きな業務シナリオ

もちろん、すべての処理にUseCase層が必要なわけではありません。単純なCRUDなら、ControllerとFormRequestで十分な場合もあります。ただし、複数のテーブル更新が絡むなら話は変わります。外部API、ジョブ投入、メール送信、状態遷移が絡む場合も同じです。その場合は、UseCaseとして分ける価値があります。

Service層を作る判断基準

Service層を作るかどうかは、名前で決めません。変更理由で決めます。例えば、価格計算ルールが複数画面から使われる場合があります。その場合は、PriceCalculatorやPricingServiceとして分けます。

一方で、Controllerから切り出しただけのOrderServiceは危険です。登録、更新、削除、検索、通知、CSV出力を全部入れると、肥大化の場所が移るだけです。つまり、Service層設計では「Controllerから逃がす」だけでは足りません。責務を言語化することが大切です。

FormRequest、Service層、Eloquentの責務分離

Laravelでは、入力検証をFormRequestに寄せられます。公式のValidationでも、Form Request Validationが説明されています。まず、入力形式の検証はFormRequestに寄せます。そうするとControllerが読みやすくなります。

ただし、FormRequestに業務ルールを詰め込みすぎると別の問題が起きます。例えば、「このユーザーはこの商品を購入できるか」は業務判断です。「現在の在庫で引き当てできるか」も同じです。また、キャンペーン適用後の金額も入力形式ではありません。そのため、UseCaseやServiceで扱うほうが再利用しやすくなります。

final class StoreOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'quantity' => ['required', 'integer', 'min:1'],
        ];
    }
}

final class OrderController
{
    public function store(StoreOrderRequest $request, CreateOrderUseCase $useCase): JsonResponse
    {
        $order = $useCase->handle(
            user: $request->user(),
            input: CreateOrderInput::fromArray($request->validated()),
        );

        return response()->json([
            'id' => $order->id,
        ], 201);
    }
}

この形にすると、ControllerはHTTPの入口として読みやすくなります。また、入力形式はFormRequest、業務操作はUseCaseに分かれます。その結果、テスト対象も分けやすくなります。

Eloquent Modelには、リレーションやスコープを置けます。単純な状態確認も候補です。一方で、複数のModelをまたいだ処理フローはUseCaseに置きます。そのほうが見通しは良くなります。Modelに何でも置く設計も、Controller肥大化と同じです。読みやすさを失いやすくなります。

Laravel サービス層 設計でトランザクション境界を決める

さらに、更新系のUseCaseではトランザクション境界が重要です。Laravel公式のDatabase Transactionsでは、transactionメソッドが説明されています。DBファサードで扱える機能です。

実務では、Controller全体を何となく包まないほうが安全です。1つの業務操作として成功させたい範囲をUseCaseに寄せます。失敗時に戻したい範囲も同じ場所で考えます。外部APIやメール送信をどこに置くかも、ここで検討します。

final class CreateOrderUseCase
{
    public function __construct(
        private StockService $stockService,
        private PriceService $priceService,
    ) {}

    public function handle(User $user, CreateOrderInput $input): Order
    {
        return DB::transaction(function () use ($user, $input) {
            $product = Product::lockForUpdate()->findOrFail($input->productId);

            $this->stockService->ensureReservable($product, $input->quantity);
            $total = $this->priceService->calculate($product, $input->quantity, $user);

            $order = Order::create([
                'user_id' => $user->id,
                'product_id' => $product->id,
                'quantity' => $input->quantity,
                'total' => $total,
            ]);

            $product->decrement('stock', $input->quantity);

            return $order;
        });
    }
}

この例では、在庫確認、金額計算、注文作成、在庫減算が1つの業務操作です。そのため、UseCaseが自然な境界になります。ただし、メール送信を同じトランザクション内に入れるかは慎重に判断します。外部決済APIの呼び出しも同じです。

例えば、DBロックを持ったまま外部APIを待つと、待ち時間が長くなります。その結果、ロック競合やタイムアウトを招くことがあります。そこで、DB更新後にイベントやQueueへ逃がす設計も候補になります。

サービス層はLaravelのDIでテストしやすくする

サービス層設計の利点は、コードを分けることだけではありません。テストしやすい設計にできる点も大きいです。

LaravelにはService Containerがあります。公式のService Containerでも、依存関係の注入が説明されています。自動解決も扱われています。ControllerやUseCaseに依存クラスを型で渡せます。そのため、テスト時に差し替えやすくなります。

final class PriceService
{
    public function calculate(Product $product, int $quantity, User $user): int
    {
        $subtotal = $product->price * $quantity;

        if ($user->isPremium()) {
            return (int) floor($subtotal * 0.9);
        }

        return $subtotal;
    }
}

このようなServiceは、HTTPリクエストから切り離してテストできます。DB保存にも依存しません。まず価格計算だけを小さく確認できます。さらに、UseCaseのテストではDB更新や例外時の挙動を確認します。

ただし、何でもinterface化すればよいわけではありません。実装が1つしかないクラスまで抽象化すると、読むファイルが増えます。差し替え予定がない場合も同じです。一方で、外部決済やメール配信は差し替えたい依存です。ストレージや外部APIクライアントも候補です。こうした依存には、インターフェースを用意する価値があります。

コードレビューで見られやすいサービス層設計の観点

最後に、Laravelのサービス層設計でレビューされやすい観点を整理します。レビューでは、クラスを分けた事実よりも、分け方の一貫性が見られます。

  • ControllerがHTTPの入口に集中しているか
  • FormRequestに入力形式の検証以上の業務判断を入れすぎていないか
  • UseCaseが1つの業務操作として読めるか
  • Serviceが何でも屋になっていないか
  • Eloquent Modelに画面都合や処理フローを詰め込みすぎていないか
  • トランザクション境界が業務上の一貫性と合っているか
  • 外部API、メール、Queue、イベントの実行タイミングが安全か
  • テストで確認したい単位に分かれているか

特に注意したいのは、Serviceという名前の巨大クラスです。OrderServiceに注文関連の全処理が集まると危険です。Controller肥大化がService肥大化に変わるだけです。そのため、役割が見える名前に分けます。例えば、CreateOrderUseCaseやCancelOrderUseCaseです。PriceServiceやStockServiceも意図が伝わりやすい名前です。

よくある質問

Laravelで必ずService層を作るべきですか?

必ずではありません。単純なCRUDなら、ControllerとFormRequestで十分な場合があります。Eloquentだけで読みやすく収まることもあります。ただし、業務判断が増えるなら分けます。複数DB更新、外部連携、状態遷移が増える場合も同じです。

Repository層も作ったほうがよいですか?

LaravelではEloquent自体が強力です。そのため、機械的にRepository層を作る必要はありません。一方で、複雑な検索条件を再利用したい場合は候補になります。外部ストレージを永続化先として隠したい場合も同じです。その場合は、RepositoryやQuery専用クラスが役立つことがあります。

UseCase層とService層の名前はどう決めるべきですか?

まず、UseCaseは動詞に近い業務操作名にします。CreateOrderUseCaseのような名前です。ApproveApplicationUseCaseも同じ考え方です。一方で、Serviceは業務部品として名前を付けます。PriceServiceやStockServiceのようにします。何を担当するかが見える名前にすることが重要です。

まとめ

Laravel サービス層 設計では、Controllerを短くすることだけを目的にしないほうがよいです。重要なのは、変更理由を分けることです。HTTPの都合、入力検証、業務判断、永続化、外部連携を分けます。

まず、FormRequestは入力形式の検証に寄せます。次に、UseCaseは1つの業務操作の流れを表します。さらに、Serviceは再利用される業務ロジックを担当します。この分け方にすると、コードレビューでも意図が伝わりやすくなります。

また、トランザクション境界とテスト単位も設計の一部です。DB更新、外部API、メール送信、Queue投入の順序を決めます。その順序によって、障害時の挙動が変わります。そのため、サービス層設計は単なるディレクトリ構成ではありません。業務システムを壊しにくくするための判断です。

PHP/LaravelやWeb系業務システムの案件では、実装スピードだけでは足りません。責務分離、テストしやすさ、レビューしやすさも求められます。今の経験を活かせる案件を探したい方もいると思います。Laravelの設計や保守改善に関われる環境を相談したい方も、カジュアル面談で現在地を聞かせてください。

IaC INP PM PMO PMP UX Webディレクター インフラエンジニア キャリアチェンジ フロントエンドエンジニア