Service layer pattern — keeping controllers thin
Concept
Form Requests and Data Transfer Objects (DTOs) clean up controller method signatures and make intent explicit. Rather than working with raw $request->input('field') throughout your code, you pass structured objects with typed properties.
Form Requests (covered in lv-11) handle validation and authorization. After validation, $request->validated() returns the safe array. The Form Request class documents what data the endpoint expects.
DTOs: Plain PHP classes (or readonly classes in PHP 8.2+) that carry data between layers. Converting a validated array to a DTO gives you type safety, IDE autocompletion, and self-documenting code.
When to use DTOs:
- When passing validated data from a controller to a service (avoids passing arrays).
- When a service method takes many parameters.
- When the same data structure is used in multiple places.
- PHP 8.2+ readonly classes make DTOs zero-boilerplate.
DTO vs. Form Request: Form Request is the HTTP layer (validation, authorization). DTO is the domain layer (structured data). Controllers create DTOs from Form Requests and pass them to services.
spatie/laravel-data: A popular package that combines validation, DTO, and API Resource in one class. Reduces boilerplate significantly.
Value Objects: Like DTOs but immutable and with domain logic. Money, Email, Address — wrap primitives with behavior.
Code Example
<?php
// DTO using PHP 8.2 readonly class
namespace App\DataTransferObjects;
final readonly class CreateOrderData
{
public function __construct(
public readonly int $userId,
public readonly array $items, // [{product_id, quantity}]
public readonly ?int $couponId,
public readonly ?string $notes,
) {}
public static function fromRequest(\App\Http\Requests\CreateOrderRequest $request): self
{
return new self(
userId: $request->user()->id,
items: $request->validated('items'),
couponId: $request->validated('coupon_id'),
notes: $request->validated('notes'),
);
}
}
// Form Request
namespace App\Http\Requests;
class CreateOrderRequest extends \Illuminate\Foundation\Http\FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|integer|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
'coupon_id' => 'nullable|integer|exists:coupons,id',
'notes' => 'nullable|string|max:500',
];
}
}
// Controller — very thin
class OrderController extends \Illuminate\Routing\Controller
{
public function __construct(private readonly \App\Services\OrderService $orders) {}
public function store(CreateOrderRequest $request): \Illuminate\Http\JsonResponse
{
$data = CreateOrderData::fromRequest($request);
$order = $this->orders->create($data); // service gets typed data, not array
return response()->json($order, 201);
}
}
// Service — accepts typed DTO
class OrderService
{
public function create(\App\DataTransferObjects\CreateOrderData $data): \App\Models\Order
{
// $data->userId, $data->items, $data->couponId — all typed, IDE-friendly
return \App\Models\Order::create([
'user_id' => $data->userId,
'coupon_id' => $data->couponId,
'notes' => $data->notes,
]);
}
}