Designing the Presentation Layer — FormRequest, Thin Controllers, and API Resources
What is the presentation layer
In the previous chapter you learned about the use case layer. This chapter explains the design of the presentation layer that handles HTTP requests/responses.
The presentation layer has the following responsibilities:
- Validating input: checking the format of the request
- Converting to a Command: turning the request into the application layer's input format
- Building the response: converting a DTO into an HTTP response
- Authentication and authorization: access control
Keep the Controller thin (a thin controller) and delegate business logic to the UseCase.
Validation with FormRequest
A basic FormRequest
// app/Http/Requests/Order/CreateOrderRequest.php
final class CreateOrderRequest extends FormRequest
{
public function authorize(): bool
{
// Authorization check (implement as needed)
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' => 'An order requires at least one item',
'items.min' => 'An order requires at least one item',
'items.*.quantity.max' => 'The maximum quantity per item is 100',
];
}
/**
* Method that converts to a Command
*/
public function toCommand(): CreateOrderCommand
{
return new CreateOrderCommand(
$this->input('shipping.prefecture'),
$this->input('shipping.city'),
$this->input('shipping.street'),
$this->input('items'),
);
}
}
Simple additional validation
// app/Http/Requests/Order/ConfirmOrderRequest.php
final class ConfirmOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'order_id' => [
'required',
'integer',
'exists:orders,id', // exists in the DB (referential integrity)
],
];
}
public function toCommand(): ConfirmOrderCommand
{
return new ConfirmOrderCommand($this->input('order_id'));
}
}
Performing state checks such as "is this order already confirmed?" in a FormRequest's withValidator() is an anti-pattern. The reason is explained in the next section, but the key point is to follow the principle that business rules are consolidated in the domain layer. Keep the FormRequest devoted to format checks only.
Dividing validation responsibilities between FormRequest and the domain layer
Validation is performed in two stages: in the FormRequest and in the domain layer. It is important to clearly understand each one's responsibilities.
FormRequest (presentation layer) responsibilities:
- Format validation: whether the "shape" of the data is correct
- Checking that the HTTP request has the correct structure
- Early return (rejecting invalid requests at an early stage)
Domain layer responsibilities:
- Business-rule validation: whether the operation is "permitted"
- Checking whether the operation is executable in the current state
- Judgments based on domain knowledge
Decision criteria:
- "Is it valid as an HTTP request?" → FormRequest
- "Is it permitted from a business standpoint?" → domain layer
Understanding the division of responsibilities with concrete examples
| What is checked | Responsible layer | Example | Reason |
|---|---|---|---|
| Required check | FormRequest | 'name' => 'required' | A formal requirement of the request |
| Type check | FormRequest | 'price' => 'integer' | Validity of the data type |
| Range check | FormRequest | 'quantity' => 'min:1|max:100' | A general constraint (decidable in the UI layer) |
| Existence check | FormRequest | 'product_id' => 'exists:products' | Referential integrity (a DB-layer constraint) |
| State-transition check | Domain | exception in $order->confirm() | A business rule (domain knowledge) |
| Stock check | Domain | exception in $inventory->decrease() | A business rule (real-time judgment) |
A general constraint such as 'quantity' => 'min:1|max:100' is naturally handled in the FormRequest. On the other hand, a constraint that involves domain knowledge, such as "the maximum quantity differs per product" or "the limit changes depending on the user's rank," should be judged in the domain layer. When in doubt, ask yourself: "Is this constraint a business rule, or a requirement of the request format?"
Implementation example:
// FormRequest: format validation
class ConfirmOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'order_id' => ['required', 'integer', 'exists:orders,id'],
// ↑ Is it valid as a request? (format)
];
}
}
// UseCase: running domain validation
class ConfirmOrderUseCase
{
public function execute(ConfirmOrderCommand $command): void
{
$order = $this->orderRepository->findById(new OrderId($command->orderId));
// ↓ Is it permitted from a business standpoint? (business rule)
$order->confirm(); // throws unless in the DRAFT state
$this->orderRepository->save($order);
}
}
// Domain entity: implementing the business rule
class Order
{
public function confirm(): void
{
// Business-rule validation
if ($this->status !== OrderStatus::DRAFT) {
throw new DomainException('Only draft orders can be confirmed');
}
if (count($this->orderLines) === 0) {
throw new DomainException('Cannot confirm an order with no line items');
}
$this->status = OrderStatus::CONFIRMED;
}
}
Why split into two stages?
- Early return: reject invalid requests at an early stage (performance improvement)
- Clear responsibilities: separate format checks from business rules
- Optimized error messages:
- FormRequest: a user-friendly message with HTTP status 422 (Unprocessable Entity)
- Domain: a clear error as a business-rule violation (HTTP status 400, etc.)
Designing the Controller
The design philosophy of a thin controller
The Controller is responsible only for input/output conversion and holds no business logic.
A "thin controller" is an important design principle in the MVC pattern. If you write business logic in the Controller, the following problems occur.
1. Testing becomes difficult
// NG: business logic in the Controller
public function confirm(Request $request, int $id): JsonResponse
{
$order = Order::find($id);
// Business logic in the Controller
if ($order->status !== 'draft') {
return response()->json(['error' => 'Cannot confirm'], 400);
}
if ($order->orderLines->isEmpty()) {
return response()->json(['error' => 'Line items are empty'], 400);
}
$order->status = 'confirmed';
$order->save();
return response()->json(['message' => 'Confirmed']);
}
// Problems:
// - Testing the business logic requires an HTTP request
// - The Controller's tests become bloated
// - The same logic cannot be reused in another Controller (such as an admin screen)
// OK: delegate to the UseCase
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json(['message' => 'Confirmed']);
}
// Benefits:
// - Business logic is tested in the UseCase
// - The Controller only tests "converting the HTTP request to the UseCase"
// - The UseCase can also be used from other Controllers (CLI, batch, etc.)
2. Low reusability Bad example — business logic duplicated in the Controllers (the same logic implemented 3 times):
Good example — consolidated in the UseCase (business logic in one place):
3. Business logic coupled to HTTP
- The business logic depends on the HTTP request format
- It is hard to support other protocols such as GraphQL or gRPC
- Reuse in CLI or batch processing is impossible
Controller responsibilities (only these!)
- Validating the request (format check)
- Converting the request to a Command
- Calling the UseCase
- Converting the result to an HTTP response
- Checking authentication and authorization
What the Controller must NOT hold
- Business rules (state checks with if statements, etc.)
- Database operations
- External API calls
- Domain knowledge
An implementation example of a thin 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,
) {}
/**
* Create an order
*
* @return JsonResponse 201 Created
*/
public function store(CreateOrderRequest $request): JsonResponse
{
$orderId = $this->createOrderUseCase->execute($request->toCommand());
return response()->json([
'id' => $orderId->value(),
'message' => 'Order created',
], Response::HTTP_CREATED);
}
/**
* Confirm an order
*/
public function confirm(int $id): JsonResponse
{
$this->confirmOrderUseCase->execute(new ConfirmOrderCommand($id));
return response()->json([
'message' => 'Order confirmed',
]);
}
/**
* Retrieve order details
*/
public function show(int $id): OrderResource
{
$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}
/**
* Retrieve a list of orders
*/
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);
}
}
Routing
// 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']);
});
Building responses with API Resources
Resource for details
// 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 for lists
// 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,
];
}
}
Example responses
// GET /api/orders/1
{
"data": {
"id": 1,
"status": "confirmed",
"shippingAddress": "1-1-1 Shibuya, Tokyo",
"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"
}
]
}
Authentication and authorization
Authentication with middleware
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::prefix('orders')->group(function () {
Route::get('/', [OrderController::class, 'index']);
Route::post('/', [OrderController::class, 'store']);
// ...
});
});
Authorization with Policy
// app/Policies/OrderPolicy.php
final class OrderPolicy
{
/**
* Can the order be viewed? (authorization: WHO)
*/
public function view(User $user, Order $order): bool
{
// Only your own orders can be viewed
return $user->id === $order->user_id;
}
/**
* Can the order be confirmed? (authorization: WHO)
*
* Note: state-transition rules such as "only DRAFT orders can be confirmed"
* are judged in the domain layer (Order::confirm()). The Policy only checks ownership.
*/
public function confirm(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
/**
* Can the order be cancelled? (authorization: WHO)
*
* Note: state rules such as "shipped orders cannot be cancelled"
* are judged in the domain layer (Order::cancel()). The Policy only checks ownership.
*/
public function cancel(User $user, Order $order): bool
{
return $user->id === $order->user_id;
}
}
// Usage inside the Controller
public function show(int $id): OrderResource
{
$order = Order::findOrFail($id);
$this->authorize('view', $order);
$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}
The difference between authorization and domain rules
| Type | Responsible | Example |
|---|---|---|
| Authorization | Policy | "Only the owner can operate on this order" |
| Domain rule | Entity | "Items cannot be added to a confirmed order" |
Directory structure
app/Http/
├── Controllers/
│ └── Api/
│ ├── OrderController.php
│ └── UserController.php
│
├── Requests/
│ └── Order/
│ ├── CreateOrderRequest.php
│ ├── ConfirmOrderRequest.php
│ └── CancelOrderRequest.php
│
├── Resources/
│ └── Order/
│ ├── OrderResource.php
│ └── OrderListResource.php
│
└── Middleware/
└── ...
Summary
| Point | Description |
|---|---|
| FormRequest | Responsible for input format validation |
| Thin controller | Only input/output conversion; holds no business logic |
| API Resource | Responsible for building the response |
| Policy | Responsible for authorization (who can do what) |
| Division of responsibilities | Format check → FormRequest, business rule → Domain |
References
If you want to learn more about Laravel's presentation-layer features, refer to the official documentation.
Laravel official documentation
- Laravel FormRequest Validation
- Details of validation with FormRequest
- Laravel API Resources
- How to build API responses with Eloquent Resources
- Laravel Authorization - Policies
- How to implement authorization with Policies
In the next chapter, we will take a detailed look at the repository pattern.