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'),
// The owner of the order, taken from the authenticated user
// (this endpoint sits under the auth:sanctum group shown later)
$this->user()->id,
);
}
}
Simple additional validation
// app/Http/Requests/Order/ConfirmOrderRequest.php
final class ConfirmOrderRequest extends FormRequest
{
/**
* The order ID arrives as a route parameter.
* A FormRequest only validates the body, the query string and files,
* so move it into the validated input before rules() is applied.
*/
protected function prepareForValidation(): void
{
$this->merge(['order_id' => $this->route('id')]);
}
public function rules(): array
{
return [
'order_id' => [
'required',
'integer',
],
];
}
public function toCommand(): ConfirmOrderCommand
{
return new ConfirmOrderCommand((int) $this->input('order_id'));
}
}
Cancelling also takes a reason. CancelOrderCommand (Chapter 11) makes reason required, so the FormRequest requires it too.
// app/Http/Requests/Order/CancelOrderRequest.php
final class CancelOrderRequest extends FormRequest
{
protected function prepareForValidation(): void
{
$this->merge(['order_id' => $this->route('id')]);
}
public function rules(): array
{
return [
'order_id' => ['required', 'integer'],
'reason' => ['required', 'string', 'max:255'],
];
}
public function toCommand(): CancelOrderCommand
{
return new CancelOrderCommand(
(int) $this->input('order_id'),
(string) $this->input('reason'),
);
}
}
Performing state checks such as "is this order already confirmed?" in a FormRequest's withValidator() is an anti-pattern. The same rule ends up split across the FormRequest and the domain layer, and the FormRequest only runs when there is an HTTP request. Call the same UseCase from a batch job or a queue worker and the state check alone is bypassed. Follow the principle that business rules are consolidated in the domain layer and 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 | Domain | InventoryNotFoundException when findByProductId() returns null | Referential integrity surfaces on fetch (mapped to 404 in Chapter 16) |
| 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:
The ConfirmOrderRequest shown under "Simple additional validation" only checks whether the request itself is well formed. 'order_id' => ['required', 'integer'] is that format check. Whether the order exists is not checked here.
Whether it exists, and whether the operation is permitted from a business standpoint, is judged by the two layers behind it.
// UseCase: fetch the domain object and call it (implemented in Chapter 11)
$orderId = new OrderId($command->orderId);
$order = $this->orderRepository->findById($orderId)
?? throw new OrderNotFoundException($orderId); // ← the existence check lives here
$order->confirm(); // ← the business rule is judged here
$this->orderRepository->save($order);
// Domain entity: implementing the business rule (implemented in Chapter 6, Order::confirm())
public function confirm(): void
{
if (!$this->status->canBeConfirmed()) {
throw new InvalidOrderStateException('This order cannot be confirmed');
}
if (empty($this->orderLines)) {
throw new InvalidOrderStateException('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/OrderController.php
final class OrderController extends Controller
{
public function __construct(
private readonly CreateOrderUseCase $createOrderUseCase,
private readonly ConfirmOrderUseCase $confirmOrderUseCase,
private readonly CancelOrderUseCase $cancelOrderUseCase,
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(ConfirmOrderRequest $request): JsonResponse
{
$this->confirmOrderUseCase->execute($request->toCommand());
return response()->json([
'message' => 'Order confirmed',
]);
}
/**
* Cancel an order
*/
public function cancel(CancelOrderRequest $request): JsonResponse
{
$this->cancelOrderUseCase->execute($request->toCommand());
return response()->json([
'message' => 'Order cancelled',
]);
}
/**
* 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/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 for lists
// 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,
];
}
}
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
What the Policy receives is not the Order domain entity but OrderModel from the persistence layer (Chapter 13). The user_id that identifies the owner is a table column, and the domain entity carries no notion of a customer.
The administrator bypass lives in before() so that role checks are not scattered across every method. The Role enum used here, and the $casts entry on the User model that turns $user->role into that enum, are defined in Chapter 18, "Authentication and authorization". Without the cast, $user->role stays a string and the enum comparison never holds.
// app/Policies/OrderPolicy.php
final class OrderPolicy
{
/**
* Pre-authorization check (authorization: WHO)
*
* Administrators are allowed every action. Returning null falls through to
* the methods below; returning false would deny every action outright.
*/
public function before(User $user, string $ability): bool|null
{
return $user->role === Role::ADMIN ? true : null;
}
/**
* Can the order be viewed? (authorization: WHO)
*/
public function view(User $user, OrderModel $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, OrderModel $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, OrderModel $order): bool
{
return $user->id === $order->user_id;
}
}
Laravel looks for OrderModelPolicy based on the model name, so this naming is not resolved automatically. Register the pair explicitly in a service provider.
// In boot() of app/Providers/AppServiceProvider.php
Gate::policy(OrderModel::class, OrderPolicy::class);
In the controller, add authorization to the show() and cancel() we wrote earlier.
// Usage inside the Controller (the earlier show() and cancel() with authorization added)
public function show(int $id): OrderResource
{
$order = OrderModel::findOrFail($id);
Gate::authorize('view', $order);
$orderDto = $this->getOrderUseCase->execute($id);
return new OrderResource($orderDto);
}
public function cancel(CancelOrderRequest $request, int $id): JsonResponse
{
// findOrFail() is here to obtain the target the Policy needs, not to validate input.
// The existence check itself belongs to the UseCase's findById() (see the split above).
$order = OrderModel::findOrFail($id);
Gate::authorize('cancel', $order);
$this->cancelOrderUseCase->execute($request->toCommand());
return response()->json(['message' => 'Order cancelled']);
}
$this->authorize()?Up to Laravel 10 the base Controller class carried the AuthorizesRequests trait, which made $this->authorize() available. In Laravel 11 the base Controller is an empty abstract class, so that method does not exist. Use Gate::authorize(), or use AuthorizesRequests explicitly in your controller.
The difference between authorization and domain rules
| Type | Responsible | Example |
|---|---|---|
| Authorization | Policy | "Only the owner or an admin 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.