プレゼンテーション層の設計 — FormRequest・薄い Controller・API Resource
プレゼンテーション層とは
前章でユースケース層を学びました。本章では、HTTPリクエスト/レスポンスを処理するプレゼンテーション層の設計について解説します。
プレゼンテーション層は以下の責務を持ちます。
- 入力のバリデーション:リクエストの形式チェック
- Commandへの変換:リクエストをアプリケーション層の入力形式に
- レスポンスの構築:DTOをHTTPレスポンスに変換
- 認証・認可:アクセス制御
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
{
public function rules(): array
{
return [
'order_id' => [
'required',
'integer',
'exists:orders,id', // DBに存在するか(参照整合性)
],
];
}
public function toCommand(): ConfirmOrderCommand
{
return new ConfirmOrderCommand($this->input('order_id'));
}
}
「この注文は既に確定されているか?」のような状態チェックを FormRequest の withValidator() で実施するのはアンチパターンです。理由は次のセクションで解説しますが、要点は ビジネスルールはドメイン層に集約する という原則に従うこと。FormRequest は形式チェックのみに徹してください。
FormRequestとドメイン層のバリデーション役割分担
バリデーションは、FormRequestとドメイン層の2段階で行います。それぞれの責務を明確に理解することが重要です。
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 で扱うのが自然です。一方、「商品ごとに上限数量が異なる」「ユーザーランクによって制限が変わる」のような ドメイン知識を伴う制約 はドメイン層で判定してください。迷ったら「この制約はビジネスルールか、それともリクエスト形式の要件か?」を自問しましょう。
実装例:
// FormRequest: 形式的なバリデーション
class ConfirmOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'order_id' => ['required', 'integer', 'exists:orders,id'],
// ↑ リクエストとして正しいか?(形式的)
];
}
}
// UseCase: ドメインバリデーションの実行
class ConfirmOrderUseCase
{
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(new OrderId($command->orderId));
// ↓ ビジネス的に許可されるか?(ビジネスルール)
$order->confirm(); // DRAFT状態でないと例外
$this->orderRepository->save($order);
}
}
// ドメインエンティティ: ビジネスルールの実装
class Order
{
public function confirm(): void
{
// ビジネスルールのバリデーション
if ($this->status !== OrderStatus::DRAFT) {
throw new DomainException('下書き状態の注文のみ確定できます');
}
if (count($this->orderLines) === 0) {
throw new DomainException('明細が空の注文は確定できません');
}
$this->status = OrderStatus::CONFIRMED;
}
}
なぜ2段階に分けるのか?
- 早期リターン: 無効なリクエストを早い段階で弾く(パフォーマンス向上)
- 責務の明確化: 形式チェックとビジネスルールを分離
- エラーメッセージの最適化:
- FormRequest: HTTPステータス422(Unprocessable Entity)でユーザーフレンドリーなメッセージ
- ドメイン: ビジネスルール違反として明確なエラー(HTTPステータス400など)
Controllerの設計
薄いController(Thin 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の責務(これだけ!)
- リクエストのバリデーション(形式チェック)
- リクエストをCommandに変換
- UseCaseの呼び出し
- 結果をHTTPレスポンスに変換
- 認証・認可の確認
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(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
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']);
Route::post('/{id}/cancel', [OrderController::class, 'cancel']);
});
API Resourceによるレスポンス構築
詳細用Resource
// app/Http/Resources/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/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による認可
// app/Policies/OrderPolicy.php
final class OrderPolicy
{
/**
* 注文を閲覧できるか(認可: WHO)
*/
public function view(User $user, Order $order): bool
{
// 自分の注文のみ閲覧可能
return $user->id === $order->user_id;
}
/**
* 注文を確定できるか(認可: WHO)
*
* 注意: 「DRAFT状態のみ確定可能」のような状態遷移ルールは
* ドメイン層(Order::confirm())で判定する。Policyは所有者チェックのみ。
*/
public function confirm(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
/**
* 注文をキャンセルできるか(認可: WHO)
*
* 注意: 「発送済みはキャンセル不可」などの状態ルールは
* ドメイン層(Order::cancel())で判定する。Policyは所有者チェックのみ。
*/
public function cancel(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}
// Controller内での使用
public function show(int $id): OrderResource
{
$order = Order::findOrFail($id);
$this->authorize('view', $order);
$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}
認可とドメインルールの違い
| 種類 | 担当 | 例 |
|---|---|---|
| 認可 | Policy | 「この注文を操作できるのは所有者のみ」 |
| ドメインルール | Entity | 「確定済みの注文には商品を追加できない」 |
ディレクトリ構成
app/Http/
├── Controllers/
│ └── Api/
│ ├── OrderController.php
│ └── UserController.php
│
├── Requests/
│ └── Order/
│ ├── CreateOrderRequest.php
│ ├── ConfirmOrderRequest.php
│ └── CancelOrderRequest.php
│
├── Resources/
│ └── Order/
│ ├── OrderResource.php
│ └── OrderListResource.php
│
└── Middleware/
└── ...
まとめ
| ポイント | 説明 |
|---|---|
| FormRequest | 入力の形式バリデーションを担当 |
| 薄いController | 入出力の変換のみ、ビジネスロジックを持たない |
| API Resource | レスポンスの構築を担当 |
| Policy | 認可(誰が何をできるか)を担当 |
| 役割分担 | 形式チェック→FormRequest、ビジネスルール→Domain |
参考資料
Laravelのプレゼンテーション層機能についてさらに学びたい方は、公式ドキュメントを参照してください。
Laravel公式ドキュメント
- Laravel FormRequest Validation
- FormRequestによるバリデーションの詳細
- Laravel API Resources
- Eloquent Resourceを使ったAPIレスポンスの構築方法
- Laravel Authorization - Policies
- Policyを使った認可の実装方法
次のチャプターでは、リポジトリパターンについて詳しく見ていきます。