0

Service layer pattern — keeping controllers thin

Intermediate5 min read·lv-27-002
interviewsolid

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
<?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,
        ]);
    }
}