A service layer is where the fat controller was going anyway

Business logic in a controller is reachable from exactly one place: an HTTP request. The first time the same operation is needed from a console command, a queue worker or a test, it gets copied — and the two copies drift.

final class PlaceOrder
{
    private $orders;
    private $payments;

    public function __construct(Orders $orders, Payments $payments)
    {
        $this->orders   = $orders;
        $this->payments = $payments;
    }

    public function __invoke(Cart $cart, PaymentMethod $method)
    {
        // the actual operation, with no request or response in sight
    }
}

The test for whether the extraction is real: the service must not mention the request, the session, or anything about how it was triggered. If it needs the current user, that is a parameter. Controllers then shrink to what they are for — parse input, call the operation, choose a response — and the same class serves the CLI without modification.