Validate input, reserve stock, create the order, charge the card, fire events. Five steps in one checkout action, and the fat model versus fat controller argument offers only two rooms for them. Both rooms are wrong.
Put it all in the controller and you cannot run checkout from anywhere except HTTP. No console command, no queue job, no test without the kernel. Put it in the Order model and the model now knows about payments, stock and notifications, a strange set of friends for an Eloquent class.
The third room is a plain use-case class. No DDD, no layers, no ceremony. One class, one public method, dependencies in the constructor:
public function store(CheckoutRequest $request, Checkout $checkout)
{
$order = $checkout->handle(
$request->user()->id,
$request->validated()
);
return redirect()->route('orders.show', $order);
}
The form request checks shape at the HTTP boundary: fields present, types correct, address not empty. Checkout::handle() owns the business part: stock, payment, order creation, in what order, what happens on failure. Persistence stays where it was, in Eloquent.
The payoff shows in tests. A use-case class is constructed and called directly. No HTTP kernel, no routes, no middleware. Fast tests for every branch of the discount logic, plus a couple of slow feature tests through HTTP to check the wiring. Testing every discount edge case through full requests is how a suite gets to twenty minutes. Ours did.
One caution against the opposite religion. A controller that loads a model and returns a view does not need a service. Extracting ShowOrderService with one line inside is cargo cult, and I have written that class. The use-case class earns its place when there is orchestration to own. No orchestration, no class.