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層は再利用される業務部品として扱います。価格計算、在庫引当、通知文面生成などが候補です。
| 層 | 主な責務 | 置かないほうがよいもの |
|---|---|---|
| Controller | HTTPリクエストの受け取り、認証ユーザー取得、レスポンス変換 | 業務ルール、複数DB更新、外部連携の詳細 |
| FormRequest | 入力形式の検証、簡単な認可 | DB更新、複雑な業務判断、メール送信 |
| UseCase | 1つの業務操作の流れ、トランザクション境界 | 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の設計や保守改善に関われる環境を相談したい方も、カジュアル面談で現在地を聞かせてください。
一度カジュアル面談をしませんか?
株式会社bluenaは「高還元」と「伴走支援」を両立したSES企業です。単価の81〜86%を還元する報酬体系と、専任サポーターによる隔週1on1で、エンジニアが納得できるキャリアを実現します。
まとまっていなくてもOK——まずは現在地を聞かせてください。
カジュアル面談ですので、お気軽にお聞かせください。





