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'),
// 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'),
);
}
}
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 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.

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 checkDomainInventoryNotFoundException when findByProductId() returns nullReferential integrity surfaces on fetch (mapped to 404 in Chapter 16)
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:

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?

  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/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']);
}
Why not $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

TypeResponsibleExample
AuthorizationPolicy"Only the owner or an admin 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.