Проект на Laravel начинается просто: маршрут, контроллер, модель Eloquent. Этого хватает надолго. Проблемы появляются, когда один метод контроллера валидирует данные, открывает транзакцию, списывает деньги, шлёт письмо и собирает JSON. Потом то же правило копируют в консольную команду, для теста нужен полноценный HTTP-запрос, а трогать метод никто не решается.

Паттерны архитектуры отвечают на один вопрос: в каком классе лежит какой кусок кода. Это инструменты, а не правила, и каждый стоит дополнительных файлов.

Тонкие контроллеры и DTO

Контроллер переводит HTTP в вызов и результат обратно в HTTP. Валидация и авторизация уходят в FormRequest, правило — в Action, форма ответа — в Resource.

php
public function store(Request $request)
{
    $data = $request->validate(['items' => 'required|array']);

    $order = DB::transaction(function () use ($request, $data) {
        $order = Order::create(['user_id' => $request->user()->id]);
        $order->items()->createMany($data['items']);

        return $order;
    });

    Mail::to($request->user())->send(new OrderPlacedMail($order));

    return response()->json($order, 201);
}

Тот же эндпоинт после разделения:

php
public function store(
    StoreOrderRequest $request,
    PlaceOrder $action,
): JsonResponse {
    $order = $action->handle(PlaceOrderData::fromRequest($request));

    return OrderResource::make($order)
        ->response()
        ->setStatusCode(201);
}
Путь запроса по слоям
Путь запроса по слоям

Между контроллером и Action передают DTO (data transfer object): небольшой класс с типизированными полями вместо array или всего Request.

php
namespace App\DataTransferObjects;

use App\Http\Requests\StoreOrderRequest;

final readonly class PlaceOrderData
{
    public function __construct(
        public int $userId,
        public array $items,
        public ?string $coupon = null,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        return new self(
            userId: $request->user()->id,
            items: $request->validated('items'),
            coupon: $request->validated('coupon'),
        );
    }
}

💡 readonly class требует PHP 8.2 и новее. Laravel 12 требует PHP 8.2+, так что он всегда доступен. Про HTTP знает только fromRequest(), поэтому Action можно вызвать из команд, job и тестов.

Action-классы