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.
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:
public function store(
StoreOrderRequest $request,
PlaceOrder $action,
): JsonResponse {
$order = $action->handle(PlaceOrderData::fromRequest($request));
return OrderResource::make($order)
->response()
->setStatusCode(201);
}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.
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 classneeds PHP 8.2 or newer. Laravel 12 requires PHP 8.2+, so it is always available. OnlyfromRequest()knows about HTTP, which keeps the Action usable from commands, jobs and tests.