Skip to main content

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:

  1. Validating input: checking the format of the request
  2. Converting to a Command: turning the request into the application layer's input format
  3. Building the response: converting a DTO into an HTTP response
  4. 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'));
}
}
Do not judge domain rules in a FormRequest

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.

Division of responsibilities between FormRequest and domain validation

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 checkedResponsible layerExampleReason
Required checkFormRequest'name' => 'required'A formal requirement of the request
Type checkFormRequest'price' => 'integer'Validity of the data type
Range checkFormRequest'quantity' => 'min:1|max:100'A general constraint (decidable in the UI layer)
Existence checkFormRequest'product_id' => 'exists:products'Referential integrity (a DB-layer constraint)
State-transition checkDomainexception in $order->confirm()A business rule (domain knowledge)
Stock checkDomainexception in $inventory->decrease()A business rule (real-time judgment)
The boundary between "general constraints" and "business constraints"

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?

  1. Early return: reject invalid requests at an early stage (performance improvement)
  2. Clear responsibilities: separate format checks from business rules
  3. 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.

Why should you keep the Controller thin?

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!)

  1. Validating the request (format check)
  2. Converting the request to a Command
  3. Calling the UseCase
  4. Converting the result to an HTTP response
  5. 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

TypeResponsibleExample
AuthorizationPolicy"Only the owner can operate on this order"
Domain ruleEntity"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

PointDescription
FormRequestResponsible for input format validation
Thin controllerOnly input/output conversion; holds no business logic
API ResourceResponsible for building the response
PolicyResponsible for authorization (who can do what)
Division of responsibilitiesFormat 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

In the next chapter, we will take a detailed look at the repository pattern.