Laravel Action Classes: Stop Writing Fat Controllers (Refactor 200 Lines to 15)
Laravel Action Classes: Stop Writing Fat Controllers (Refactor 200 Lines to 15)
TL;DR: Stuffing validation, payment API calls, database transactions, and email dispatches into a 200-line controller creates fragile code you can't reuse or easily unit test. Moving that workflow into an invokable Laravel Action Class shrinks your controller from 200 lines down to 15 lines (a 92% reduction) and lets you run isolated unit tests in 12ms without hitting the HTTP stack.
📺Watch the 45-Second Video Summary: Want the quick visual breakdown? Watch it on YouTube Shorts: Stop Writing Fat Controllers in Laravel.
The Checkout Nightmare: One Merge Conflict Away From an Outage
It starts innocently enough.
A junior developer writes a store() method in OrderController to handle checkout. Six months later, product asks for promo codes, Stripe 3D Secure handling, inventory decrementing, Slack alerts, and PDF receipts.
Your store() method is now 214 lines long.
Friday at 4:45 PM, someone tweaks the Stripe charge block. A merge conflict slips in, checkout throws a 500 error in production, and your CI suite didn't catch it because nobody could write a clean unit test for a method tangled up with Request, Auth::user(), Stripe SDK calls, and Mail::to().
And now the CLI team needs the exact same order creation flow inside a recurring subscription renewal job. You can't call OrderController::store() from a queue worker. So someone copy-pastes 120 lines into an Artisan command.
Now you have two bugs in two places.
The Code Trap: The 200-Line Fat Controller
Look familiar? Here's the classic bloated controller pattern hiding in thousands of production Laravel repos:
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Mail\OrderConfirmed;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;
class OrderController extends Controller
{
public function store(Request $request, StripeClient $stripe): JsonResponse
{
// 1. Inline validation rules (30+ lines in real apps)
$validated = $request->validate([
'product_id' => ['required', 'integer', 'exists:products,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:20'],
'token' => ['required', 'string'],
]);
// ❌ THE ARCHITECTURE TRAP:
// Business rules, inventory checks, external payment I/O, DB transactions,
// and notifications are all trapped inside an HTTP Controller.
$product = Product::findOrFail($validated['product_id']);
if ($product->stock < $validated['quantity']) {
return response()->json(['error' => 'Insufficient stock.'], 422);
}
$totalCents = $product->price_cents * $validated['quantity'];
$charge = $stripe->charges->create([
'amount' => $totalCents,
'currency' => 'usd',
'source' => $validated['token'],
]);
$order = DB::transaction(function () use ($request, $product, $validated, $totalCents, $charge) {
$product->decrement('stock', $validated['quantity']);
return Order::create([
'user_id' => $request->user()->id,
'product_id' => $product->id,
'quantity' => $validated['quantity'],
'total_cents' => $totalCents,
'stripe_charge_id' => $charge->id,
]);
});
Mail::to($request->user())->queue(new OrderConfirmed($order));
return response()->json(['data' => $order], 201);
}
}Under the Hood: Why Fat Controllers Break Down
This code passes code review when it's 35 lines. By line 200, four structural failures kick in:
HTTP Lock-In: The method expects an
Illuminate\Http\Requestand returns aJsonResponse. You can't invoke it from a queued job, webhook listener, or CLI command without faking an HTTP request.Impossible Unit Isolation: Testing the stock deduction or total calculation forces you to spin up Laravel's HTTP router, middleware stack, authentication guard, and JSON serialization.
Bloated Constructor Dependencies: As
OrderControlleraddsindex,update,cancel, andrefund, its constructor accumulates 9 injected services—every single endpoint pays to resolve dependencies it doesn't even use.High Cyclomatic Complexity: Validation branches, payment exceptions, discount edge cases, and response formatting collide inside one scope.
The Optimal Architecture: Thin Controller + Single Action Class
Core Rule: Controllers only speak HTTP. Business logic belongs in dedicated, invokable Action classes.
When you split HTTP plumbing from domain execution, your request lifecycle becomes predictable and reusable across web routes, API endpoints, console commands, and background workers:
flowchart TD
Req[HTTP POST /api/orders] --> FormReq[StoreOrderRequest: Auth & Validation]
FormReq --> Ctrl[OrderController@store - 15 Lines]
Ctrl -->|Passes OrderData DTO| Action[CreateOrderAction::__invoke]
Action -->|DB Transaction + Stripe| DB[(MySQL + Stripe API)]
Action -->|Returns Order Model| Ctrl
Ctrl -->|OrderResource| Res[201 JSON Response]
Job[Queued Renewal Job] -->|Reuses same DTO| Action
Test[Unit Test - 12ms] -->|Mocks Stripe, skips HTTP| ActionNotice the difference. CreateOrderAction has zero knowledge of HTTP status codes, JSON headers, or Request bags.
Step-by-Step Implementation
Step 1: Isolate Validation in a FormRequest
Move input validation and authorization out of the controller completely:
// app/Http/Requests/StoreOrderRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'product_id' => ['required', 'integer', 'exists:products,id'],
'quantity' => ['required', 'integer', 'min:1', 'max:20'],
'token' => ['required', 'string'],
];
}
// 👈 1. Convert validated HTTP input into a clean typed DTO
public function toDto(): \App\Data\OrderData
{
return new \App\Data\OrderData(
userId: (int) $this->user()->id,
productId: (int) $this->integer('product_id'),
quantity: (int) $this->integer('quantity'),
paymentToken: (string) $this->string('token'),
);
}
}Step 2: Define a TypedreadonlyDTO
A small PHP 8.2+readonly class prevents associative-array key typos ('product_id'vs'productId') across your app:
// app/Data/OrderData.php
namespace App\Data;
readonly class OrderData
{
public function __construct(
public int $userId,
public int $productId,
public int $quantity,
public string $paymentToken,
) {}
}Step 3: Build the Invokable Action Class
Put the entire domain operation inside app/Actions/Orders/CreateOrderAction.php. One class, one public __invoke() method:
// app/Actions/Orders/CreateOrderAction.php
namespace App\Actions\Orders;
use App\Data\OrderData;
use App\Exceptions\InsufficientStockException;
use App\Mail\OrderConfirmed;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Stripe\StripeClient;
class CreateOrderAction
{
// 👈 1. Inject only what THIS single action needs
public function __construct(
private readonly StripeClient $stripe,
) {}
// 👈 2. Accept a typed DTO and return a Domain Model, never JsonResponse
public function __invoke(OrderData $data): Order
{
$product = Product::findOrFail($data->productId);
if ($product->stock < $data->quantity) {
throw new InsufficientStockException("Only {$product->stock} units left.");
}
$totalCents = $product->price_cents * $data->quantity;
$charge = $this->stripe->charges->create([
'amount' => $totalCents,
'currency' => 'usd',
'source' => $data->paymentToken,
]);
$order = DB::transaction(function () use ($data, $product, $totalCents, $charge): Order {
$product->decrement('stock', $data->quantity);
return Order::create([
'user_id' => $data->userId,
'product_id' => $product->id,
'quantity' => $data->quantity,
'total_cents' => $totalCents,
'stripe_charge_id' => $charge->id,
]);
});
Mail::to($order->user)->queue(new OrderConfirmed($order));
return $order;
}
}Step 4: Shrink the Controller to 15 Clean Lines
Laravel's service container automatically resolves CreateOrderAction right inside the store() method signature:
// app/Http/Controllers/OrderController.php
namespace App\Http\Controllers;
use App\Actions\Orders\CreateOrderAction;
use App\Http\Requests\StoreOrderRequest;
use App\Http\Resources\OrderResource;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function store(
StoreOrderRequest $request,
CreateOrderAction $createOrder,
): JsonResponse {
// 👈 3. Invoke the Action like a function, then format HTTP output
$order = $createOrder($request->toDto());
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
}Look how readable that is. You can understand the entire HTTP contract in three seconds.
Bonus: Testing the Action in Complete Isolation
Now watch how clean a Pest or PHPUnit test becomes when business logic isn't trapped behind HTTP middleware:
// tests/Unit/Actions/CreateOrderActionTest.php
it('throws an exception when stock is insufficient without calling Stripe', function () {
$product = Product::factory()->create(['stock' => 1, 'price_cents' => 2500]);
$stripe = Mockery::mock(StripeClient::class);
$stripe->shouldNotReceive('charges');
$action = new CreateOrderAction($stripe);
expect(fn () => $action(new OrderData(
userId: 1,
productId: $product->id,
quantity: 5,
paymentToken: 'tok_visa',
)))->toThrow(InsufficientStockException::class);
});Zero HTTP kernel boot. Zero route matching. Runs in milliseconds.
Comparison: 200-Line Fat Controller vs. Single Action Class
Metric | 200-Line Fat Controller | 15-Line Controller + Action Class | Impact |
|---|---|---|---|
Controller Method Size |
|
| 92% smaller controller |
Reuse in Jobs / CLI | Impossible without duplication | Direct | Zero duplicated logic |
Test Isolation | Full HTTP feature test ( | Direct Action unit test ( | 15x faster feedback loop |
Dependency Injection | Bloated controller constructor | Scoped per-action constructor | Zero wasted container resolves |
Merge Conflict Risk | High (everyone edits one file) | Low (one file per business action) | Clean Git history |
4 Production Golden Rules for Laravel Action Classes
Never Pass
RequestInto an Action:
The second you type-hint
RequestinsideCreateOrderAction, you've locked that class to HTTP. Always pass areadonlyDTO or explicit typed arguments so jobs, listeners, and Artisan commands can call the exact same action.
Keep Actions Composable:
Don't cram 400 lines into one Action either. An Action can inject smaller Actions—for example,
CheckoutOrderActioncan orchestrateValidateInventoryAction,ChargeCustomerAction, andReserveStockAction.
Name Actions as<Verb><Noun>Action:
Avoid vague names like
OrderServiceorOrderManager. Those turn into 1,000-line dumping grounds. Use crisp action verbs:CreateOrderAction,CancelSubscriptionAction,ExportInvoicePdfAction.
Use DB::afterCommit() for Side Effects Inside Transactions:
If you wrap an Action inside a larger database transaction, dispatching a queued email or webhook before commit can cause a race condition where the worker queries a row that doesn't exist yet. Use
Mail::to(...)->afterCommit()->queue(...)orDB::afterCommit(...).
Frequently Asked Questions
What is a Single Action Class in Laravel?
A Single Action Class is a plain PHP class responsible for one specific business operation, such as CreateOrderAction or PublishArticleAction. It typically exposes a single public __invoke() or handle() method, keeping controllers focused strictly on HTTP requests and responses.
Should I pass Illuminate\Http\Request directly into a Laravel Action class?
No. Passing the HTTP Request object couples your business logic to the web layer, making it harder to reuse the action from Artisan commands or queued jobs. Extract validated data in a FormRequest first, then pass typed primitives or a readonly Data Transfer Object (DTO) into the action.
What is the difference between Laravel Action classes and Service classes?
Service classes tend to grow into 800-line "god classes" (like OrderService) packed with unrelated methods and tangled dependencies. An Action class enforces the Single Responsibility Principle by putting one operation per class, so each file stays small, focused, and easy to unit test.
How do Action classes make unit testing faster in Laravel?
Because an Action class does not depend on HTTP routing, middleware, or CSRF tokens, you can instantiate or resolve it directly in PHPUnit or Pest. You can mock external gateways like Stripe and test the core business rules in milliseconds without booting full HTTP feature tests.
Summary & Related Guides
Controllers have one job: accept an HTTP request, validate input, hand off work, and return an HTTP response. Moving domain workflows into Single Action Classes keeps every endpoint short, makes business rules reusable across queues and CLI commands, and turns painful integration tests into fast unit tests.
Continue Reading on KoderSolution:
Laravel API Resources & Cursor Pagination: Stop Leaking Sensitive Data
Mastering Laravel Queues: Stop Making Users Wait on API Requests
Connect with KoderSolution 🚀
Master modern software engineering, scalable architectures, and advanced backend systems with KoderSolution:
🌐Official Website: Read more architectural deep-dives and tutorials on kodersolution.com
📺YouTube Channel: Subscribe to@kodersolution on YouTube for concise, animated system design breakdowns and shorts.
📱Facebook Page: Follow our official Facebook Page for daily development tips, architecture discussions, and community updates.
💬What's your setup? How does your Laravel codebase organize business logic—Single Action Classes, Service classes, or Domain-Driven modules? Share your approach below!
Daniel O'Connor
Broke production enough times to respect database transactions and idempotent jobs. Specializes in Redis queues, Horizon workers, and webhook resilience.