メインコンテンツまでスキップ

プレゼンテーション層の設計 — FormRequest・薄い Controller・API Resource

プレゼンテーション層とは

前章でユースケース層を学びました。本章では、HTTPリクエスト/レスポンスを処理するプレゼンテーション層の設計について解説します。

プレゼンテーション層は以下の責務を持ちます。

  1. 入力のバリデーション:リクエストの形式チェック
  2. Commandへの変換:リクエストをアプリケーション層の入力形式に
  3. レスポンスの構築:DTOをHTTPレスポンスに変換
  4. 認証・認可:アクセス制御

Controller は薄く保ち(Thin Controller)、ビジネスロジックは UseCase に委譲します。

FormRequestによるバリデーション

基本的なFormRequest

// app/Http/Requests/Order/CreateOrderRequest.php
final class CreateOrderRequest extends FormRequest
{
public function authorize(): bool
{
// 認可チェック(必要に応じて実装)
return true;
}

public function rules(): array
{
return [
'shipping' => ['required', 'array'],
'shipping.prefecture' => ['required', 'string', 'max:255'],
'shipping.city' => ['required', 'string', 'max:255'],
'shipping.street' => ['required', 'string', 'max:255'],
'items' => ['required', 'array', 'min:1'],
'items.*.productId' => ['required', 'integer', 'min:1'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'items.*.unitPrice' => ['required', 'integer', 'min:0'],
];
}

public function messages(): array
{
return [
'items.required' => '注文には1つ以上の商品が必要です',
'items.min' => '注文には1つ以上の商品が必要です',
'items.*.quantity.max' => '1商品の最大数量は100個です',
];
}

/**
* Commandへの変換メソッド
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
);
}
}

シンプルな追加バリデーション

// app/Http/Requests/Order/ConfirmOrderRequest.php
final class ConfirmOrderRequest extends FormRequest
{
/**
* 注文IDはルートパラメータで渡ってくる。
* FormRequestの検証対象はボディ・クエリ・ファイルだけなので、
* ここで検証対象に移してからrules()を適用する。
*/
protected function prepareForValidation(): void
{
$this->merge(['order_id' => $this->route('id')]);
}

public function rules(): array
{
return [
'order_id' => [
'required',
'integer',
'exists:orders,id', // DBに存在するか(参照整合性)
],
];
}

public function toCommand(): ConfirmOrderCommand
{
return new ConfirmOrderCommand((int) $this->input('order_id'));
}
}
FormRequest でドメインルールを判定してはいけない

「この注文は既に確定されているか?」のような状態チェックを FormRequest の withValidator() で実施するのはアンチパターンです。同じルールが FormRequest とドメイン層の2箇所に分かれ、しかも FormRequest は HTTP リクエストがあるときしか動きません。バッチやキューのワーカーから同じ UseCase を呼ぶと状態チェックだけがすり抜けます。ビジネスルールはドメイン層に集約するという原則に従い、FormRequest は形式チェックのみに徹してください。

FormRequestとドメイン層のバリデーション役割分担

バリデーションは、FormRequestドメイン層の2段階で行います。それぞれの責務を明確に理解することが重要です。

FormRequestとドメインバリデーションの役割分担

FormRequest(プレゼンテーション層)の責務:

  • 形式的なバリデーション: データの「形」が正しいか
  • HTTPリクエストが正しい構造かをチェック
  • 早期リターン(無効なリクエストを早い段階で弾く)

ドメイン層の責務:

  • ビジネスルールのバリデーション: 操作が「許可」されるか
  • 現在の状態で操作が実行可能かをチェック
  • ドメイン知識に基づく判定

判断基準:

  • 「HTTPリクエストとして妥当か?」→ FormRequest
  • 「ビジネス的に許可されるか?」→ ドメイン層

具体例で理解する役割分担

チェック内容担当層理由
必須チェックFormRequest'name' => 'required'リクエストの形式的な要件
型チェックFormRequest'price' => 'integer'データ型の妥当性
範囲チェックFormRequest'quantity' => 'min:1|max:100'一般的な制約(UI層で判断可能)
存在チェックFormRequest'product_id' => 'exists:products'参照整合性(DB層の制約)
状態遷移チェックDomain$order->confirm() で例外ビジネスルール(ドメイン知識)
在庫チェックDomain$inventory->decrease() で例外ビジネスルール(リアルタイム判定)
「一般的な制約」と「ビジネス制約」の境界

'quantity' => 'min:1|max:100' のような一般的な制約は FormRequest で扱うのが自然です。一方、「商品ごとに上限数量が異なる」「ユーザーランクによって制限が変わる」のような ドメイン知識を伴う制約 はドメイン層で判定してください。迷ったら「この制約はビジネスルールか、それともリクエスト形式の要件か?」を自問しましょう。

実装例:

先ほどの ConfirmOrderRequest(L82付近)は、リクエストとして正しいかだけを見ます。'order_id' => ['required', 'integer', 'exists:orders,id'] がその形式チェックです。

ビジネス的に許可されるかは、その先の2層が判定します。

// UseCase: ドメインオブジェクトを取り出して呼ぶ(実装は第11章)
$order = $this->orderRepository->findById(new OrderId($command->orderId));
$order->confirm(); // ← ここでビジネスルールが判定される
$this->orderRepository->save($order);
// ドメインエンティティ: ビジネスルールの実装(実装は第6章 Order::confirm())
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new DomainException('この注文は確定できません');
}
if (empty($this->orderLines)) {
throw new DomainException('明細が空の注文は確定できません');
}

$this->status = OrderStatus::CONFIRMED;
}

なぜ2段階に分けるのか?

  1. 早期リターン: 無効なリクエストを早い段階で弾く(パフォーマンス向上)
  2. 責務の明確化: 形式チェックとビジネスルールを分離
  3. エラーメッセージの最適化:
    • FormRequest: HTTPステータス422(Unprocessable Entity)でユーザーフレンドリーなメッセージ
    • ドメイン: ビジネスルール違反として明確なエラー(HTTPステータス400など)

Controllerの設計

薄いController(Thin Controller)の設計思想

Controllerは入出力の変換のみを担当し、ビジネスロジックを持ちません。

なぜControllerを薄く保つべきか?

「薄いController(Thin Controller)」は、MVCパターンにおける重要な設計原則です。Controllerにビジネスロジックを書くと、以下の問題が発生します。

1. テストが困難になる

// NG: Controllerにビジネスロジック
public function confirm(Request $request, int $id): JsonResponse
{
$order = Order::find($id);

// ビジネスロジックがControllerに
if ($order->status !== 'draft') {
return response()->json(['error' => '確定できません'], 400);
}
if ($order->orderLines->isEmpty()) {
return response()->json(['error' => '明細が空です'], 400);
}

$order->status = 'confirmed';
$order->save();

return response()->json(['message' => '確定しました']);
}

// 問題点:
// - ビジネスロジックのテストにHTTPリクエストが必要
// - Controllerのテストが肥大化
// - 同じロジックを別のController(管理画面など)で再利用できない
// OK: UseCaseに委譲
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => '確定しました']);
}

// 利点:
// - ビジネスロジックはUseCaseでテスト
// - Controllerは「HTTPリクエスト→UseCaseへの変換」のみテスト
// - UseCaseは他のController(CLI、バッチなど)からも利用可能

2. 再利用性が低い 悪い例 — ビジネスロジックが Controller に重複(同じロジックを 3 回実装):

良い例 — UseCase に集約(ビジネスロジックは 1 箇所):

3. HTTPに依存したビジネスロジック

  • ビジネスロジックがHTTPリクエスト形式に依存
  • GraphQL、gRPCなど別のプロトコルへの対応が困難
  • CLI、バッチ処理での再利用が不可能

Controllerの責務(これだけ!)

  1. リクエストのバリデーション(形式チェック)
  2. リクエストをCommandに変換
  3. UseCaseの呼び出し
  4. 結果をHTTPレスポンスに変換
  5. 認証・認可の確認

Controllerが持ってはいけないもの

  • ビジネスルール(if文での状態チェックなど)
  • データベース操作
  • 外部API呼び出し
  • ドメイン知識

薄いControllerの実装例

// app/Http/Controllers/Api/OrderController.php
final class OrderController extends Controller
{
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
private readonly GetOrderUseCase $getOrderUseCase,
private readonly ListOrdersUseCase $listOrdersUseCase,
) {}

/**
* 注文作成
*
* @return JsonResponse 201 Created
*/
public function store(CreateOrderRequest $request): JsonResponse
{
$orderId = $this->createOrderUseCase->execute($request->toCommand());

return response()->json([
'id' => $orderId->value(),
'message' => '注文を作成しました',
], Response::HTTP_CREATED);
}

/**
* 注文確定
*/
public function confirm(ConfirmOrderRequest $request): JsonResponse
{
$this->confirmOrderUseCase->execute($request->toCommand());

return response()->json([
'message' => '注文を確定しました',
]);
}

/**
* 注文詳細取得
*/
public function show(int $id): OrderResource
{
$orderDto = $this->getOrderUseCase->execute($id);

return new OrderResource($orderDto);
}

/**
* 注文一覧取得
*/
public function index(Request $request): AnonymousResourceCollection
{
$query = new ListOrdersQuery(
status: $request->query('status'),
limit: (int) $request->query('limit', 20),
offset: (int) $request->query('offset', 0),
);

$orders = $this->listOrdersUseCase->execute($query);

return OrderListResource::collection($orders);
}
}

ルーティング

// routes/api.php
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
Route::get('/{id}', [OrderController::class, 'show']);
Route::post('/{id}/confirm', [OrderController::class, 'confirm']);
});

API Resourceによるレスポンス構築

詳細用Resource

// app/Http/Resources/Order/OrderResource.php
final class OrderResource extends JsonResource
{
/**
* @param OrderDto $resource
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'status' => $this->resource->status,
'shippingAddress' => $this->resource->shippingAddress,
'orderLines' => array_map(
fn($line) => [
'id' => $line->id,
'productId' => $line->productId,
'quantity' => $line->quantity,
'unitPrice' => $line->unitPrice,
'subtotal' => $line->subtotal,
],
$this->resource->orderLines
),
'totalAmount' => $this->resource->totalAmount,
'createdAt' => $this->resource->createdAt,
];
}
}

一覧用Resource

// app/Http/Resources/Order/OrderListResource.php
final class OrderListResource extends JsonResource
{
/**
* @param OrderListItemDto $resource
*/
public function toArray(Request $request): array
{
return [
'id' => $this->resource->id,
'status' => $this->resource->status,
'totalAmount' => $this->resource->totalAmount,
'createdAt' => $this->resource->createdAt,
];
}
}

レスポンスの例

// GET /api/orders/1
{
"data": {
"id": 1,
"status": "confirmed",
"shippingAddress": "東京都渋谷区1-1-1",
"orderLines": [
{
"id": 1,
"productId": 101,
"quantity": 2,
"unitPrice": 1000,
"subtotal": 2000
}
],
"totalAmount": 2000,
"createdAt": "2024-01-15 10:30:00"
}
}

// GET /api/orders
{
"data": [
{
"id": 1,
"status": "confirmed",
"totalAmount": 2000,
"createdAt": "2024-01-15 10:30:00"
},
{
"id": 2,
"status": "draft",
"totalAmount": 5000,
"createdAt": "2024-01-16 14:20:00"
}
]
}

認証・認可

ミドルウェアによる認証

// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
// ...
});
});

Policyによる認可

Policyが受け取るのは、ドメインエンティティの Order ではなく永続化層の OrderModel(第13章)です。所有者を表す user_id はテーブルのカラムで、ドメインエンティティは顧客の概念を持たないためです。

// app/Policies/OrderPolicy.php
final class OrderPolicy
{
/**
* 注文を閲覧できるか(認可: WHO)
*/
public function view(User $user, OrderModel $order): bool
{
// 自分の注文のみ閲覧可能
return $user->id === $order->user_id;
}

/**
* 注文を確定できるか(認可: WHO)
*
* 注意: 「DRAFT状態のみ確定可能」のような状態遷移ルールは
* ドメイン層(Order::confirm())で判定する。Policyは所有者チェックのみ。
*/
public function confirm(User $user, OrderModel $order): bool
{
return $user->id === $order->user_id;
}

/**
* 注文をキャンセルできるか(認可: WHO)
*
* 注意: 「発送済みはキャンセル不可」などの状態ルールは
* ドメイン層(Order::cancel())で判定する。Policyは所有者チェックのみ。
*/
public function cancel(User $user, OrderModel $order): bool
{
return $user->id === $order->user_id;
}
}

Laravelはモデル名から OrderModelPolicy を探すため、この命名では自動解決されません。サービスプロバイダで明示的に結びつけます。

// app/Providers/AppServiceProvider.php の boot()
Gate::policy(OrderModel::class, OrderPolicy::class);

Controllerでは、先ほどの show() に認可を足します。

// Controller内での使用(先ほどのshow()に認可を追加した版)
public function show(int $id): OrderResource
{
$order = OrderModel::findOrFail($id);

Gate::authorize('view', $order);

$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}
なぜ $this->authorize() ではないのか

Laravel 10までは基底の Controller クラスが AuthorizesRequests トレイトを持っていたため $this->authorize() が使えました。Laravel 11の基底 Controller は空のabstractクラスなので、このメソッドは存在しません。Gate::authorize() を使うか、コントローラで AuthorizesRequests を明示的にuseしてください。

認可とドメインルールの違い

種類担当
認可Policy「この注文を操作できるのは所有者のみ」
ドメインルールEntity「確定済みの注文には商品を追加できない」

ディレクトリ構成

app/Http/
├── Controllers/
│ └── Api/
│ ├── OrderController.php
│ └── UserController.php

├── Requests/
│ └── Order/
│ ├── CreateOrderRequest.php
│ └── ConfirmOrderRequest.php

├── Resources/
│ └── Order/
│ ├── OrderResource.php
│ └── OrderListResource.php

└── Middleware/
└── ...

まとめ

ポイント説明
FormRequest入力の形式バリデーションを担当
薄いController入出力の変換のみ、ビジネスロジックを持たない
API Resourceレスポンスの構築を担当
Policy認可(誰が何をできるか)を担当
役割分担形式チェック→FormRequest、ビジネスルール→Domain

参考資料

Laravelのプレゼンテーション層機能についてさらに学びたい方は、公式ドキュメントを参照してください。

Laravel公式ドキュメント

次のチャプターでは、リポジトリパターンについて詳しく見ていきます。