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.