A Laravel project starts simple: a route, a controller, an Eloquent model. That is fine for a long time. Trouble appears when one controller method validates input, opens a transaction, charges a card, sends mail and builds JSON. The same rule then gets copied into a console command, a test needs a full HTTP request and nobody dares to touch the method.

Architecture patterns answer a single question: which class holds which piece of code. They are tools, not rules, and each one costs extra files.

Thin controllers and DTOs

A controller should translate HTTP into a call and the result back into HTTP. Validation and authorization go to a FormRequest, the rule goes to an Action, the response shape goes to a 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);
}

The same endpoint after the split:

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

    return OrderResource::make($order)
        ->response()
        ->setStatusCode(201);
}
Request path through the layers
Request path through the layers

Between the controller and the Action you pass a DTO (data transfer object): a small class with typed fields instead of an array or the whole 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 needs PHP 8.2 or newer. Laravel 12 requires PHP 8.2+, so it is always available. Only fromRequest() knows about HTTP, which keeps the Action usable from commands, jobs and tests.

Action classes