Get the business logic out of your controllers
Published April 25, 2026 · by Majd Ghithan, written with the help of AI
I once opened a OrderController@store method that was 240 lines long. Validation, a discount calculation, three model writes, a Stripe charge, two emails, a Slack notification, and an if for a promo that ran only in December. It worked. It also could not be tested without booting half the app, and nobody wanted to touch it, because touching it meant reading all 240 lines to make sure you didn't break the December thing.
The problem was never that the logic was hard. It's that the logic had nowhere to live except the controller, so it all moved in.
A controller has one job: take an HTTP request, hand it to something that does the work, and turn the result into an HTTP response. That's it. It's a translator between the web and your application. The moment it starts deciding things, calculating things, charging cards, it's doing two jobs, and the second one is the one you actually care about testing.
The fat controller
Here's the shape of what I opened. Trimmed, but honest.
public function store(Request $request)
{
$data = $request->validate([
'items' => 'required|array',
'coupon' => 'nullable|string',
]);
$total = 0;
foreach ($data['items'] as $item) {
$product = Product::findOrFail($item['id']);
$total += $product->price * $item['qty'];
}
if ($data['coupon'] === 'DEC24' && now()->month === 12) {
$total *= 0.8;
}
$order = Order::create(['total' => $total, 'user_id' => auth()->id()]);
// ...charge Stripe, send emails, notify Slack...
return redirect()->route('orders.show', $order);
}
Every business rule in the application is now reachable only through an HTTP request. Want to create an order from an Artisan command? A queued job? A test? You can't, not without faking a request. The logic is welded to the transport.
Move the work into an Action
An Action is a class with one public method that does one thing. No interface, no abstract base, no ActionInterface — you don't need the ceremony. Just a class that holds a verb.
class PlaceOrder
{
public function handle(User $user, array $items, ?string $coupon): Order
{
$total = $this->total($items, $coupon);
$order = Order::create([
'total' => $total,
'user_id' => $user->id,
]);
// charge, notify — or dispatch jobs for those
return $order;
}
private function total(array $items, ?string $coupon): int
{
$total = collect($items)->sum(
fn ($i) => Product::findOrFail($i['id'])->price * $i['qty']
);
return $coupon === 'DEC24' && now()->month === 12
? (int) round($total * 0.8)
: $total;
}
}
Now the controller shrinks to what a controller is for:
public function store(Request $r)
{
// 240 lines:
// validate, price, discount,
// create, charge, email,
// notify, the December if...
}
public function store(
StoreOrderRequest $r,
PlaceOrder $place,
) {
$order = $place->handle(
$r->user(),
$r->validated('items'),
$r->validated('coupon'),
);
return redirect()->route(
'orders.show', $order
);
}
Single-action controllers when it's really one thing
If a controller has exactly one job, don't wrap it in a class with a store method — make the class itself the action with __invoke:
class PlaceOrderController
{
public function __construct(private PlaceOrder $place) {}
public function __invoke(StoreOrderRequest $request)
{
$order = $this->place->handle(
$request->user(),
$request->validated('items'),
$request->validated('coupon'),
);
return redirect()->route('orders.show', $order);
}
}
Route it directly: Route::post('/orders', PlaceOrderController::class). One route, one file, one thing. No seven-method resource controller where six methods are empty.
Action or Service — what's the difference?
An Action is one verb: PlaceOrder, CancelSubscription, RefundPayment. A Service is a small cluster of related verbs that share state or setup: a PricingService that knows about coupons, taxes, and shipping together. I reach for Actions by default and only group them into a Service when three of them keep passing the same dependencies around. Don't start with the Service. You rarely need it.
Why this actually pays off
Testing. PlaceOrder is a plain object. A unit test news it up, calls handle, asserts on the returned order. No HTTP kernel, no route, no middleware. The test that used to need a full feature test now runs in milliseconds.
Reuse. The same Action runs from the controller, from a queued job, from an Artisan command, from a webhook handler. Write the December discount once; every entry point gets it for free.
Reading. When the order logic breaks, you open PlaceOrder. You don't scroll past validation and redirect noise to find the one line that prices the coupon. The file's name tells you it's where orders get placed.
The rule I hold myself to: a controller method should read like a table of contents, not a chapter. Request in, delegate, response out. If you can't see all three in one screen without scrolling, the work is in the wrong place.
