Refactoring a fat controller in Laravel is one of the most impactful improvements you can make in a growing codebase. As projects evolve, controllers often become overloaded with validation, business logic, and side effects, making them difficult to maintain and test.
A controller starts small. Clean. Readable.
Then features get added.
Deadlines hit.
Logic piles up.
And suddenly, you’re staring at a 500-line controller that handles validation, business logic, database writes, API calls, and maybe even a bit of formatting “just for now.”
This is what we call a Fat Controller — and it’s one of the most common maintainability problems in Laravel applications.
In this article, we’ll take a real-world approach and refactor a fat controller into a cleaner, scalable structure using principles inspired by Clean Architecture.
No theory overload. Just practical steps.
Previous Article in this category : https://codecraftdiary.com/2026/03/21/ai-driven-refactoring/
The Problem: A Real Fat Controller Example
Let’s start with something painfully familiar:
class OrderController extends Controller
{
public function store(Request $request)
{
// Validation
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'items' => 'required|array',
]);
// Business logic
$total = 0;
foreach ($validated['items'] as $item) {
$product = Product::find($item['id']);
if (!$product) {
throw new Exception('Product not found');
}
if ($product->stock < $item['quantity']) {
throw new Exception('Not enough stock');
}
$total += $product->price * $item['quantity'];
$product->stock -= $item['quantity'];
$product->save();
}
// Save order
$order = Order::create([
'user_id' => $validated['user_id'],
'total' => $total,
]);
// Save items
foreach ($validated['items'] as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item['id'],
'quantity' => $item['quantity'],
]);
}
// External API call
Http::post('https://example.com/webhook', [
'order_id' => $order->id,
]);
return response()->json($order);
}
}
PHPWhat’s wrong here?
- Controller handles too many responsibilities
- Business logic is not reusable
- Hard to test
- Tight coupling to Eloquent and external APIs
- Changes are risky
Goal: What “Clean” Looks Like
We want to move toward:
- Thin controllers
- Isolated business logic
- Testable services
- Clear boundaries between layers
We’re not going full academic Clean Architecture. We’re applying just enough structure to stay sane.
Step 1: Extract Business Logic into a Service
First, move the core logic out of the controller.
class CreateOrderService
{
public function handle(array $data): Order
{
$total = 0;
foreach ($data['items'] as $item) {
$product = Product::find($item['id']);
if (!$product) {
throw new Exception('Product not found');
}
if ($product->stock < $item['quantity']) {
throw new Exception('Not enough stock');
}
$total += $product->price * $item['quantity'];
$product->stock -= $item['quantity'];
$product->save();
}
$order = Order::create([
'user_id' => $data['user_id'],
'total' => $total,
]);
foreach ($data['items'] as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item['id'],
'quantity' => $item['quantity'],
]);
}
Http::post('https://example.com/webhook', [
'order_id' => $order->id,
]);
return $order;
}
}
PHPController becomes:
class OrderController extends Controller
{
public function store(Request $request, CreateOrderService $service)
{
$validated = $request->validate([
'user_id' => 'required|exists:users,id',
'items' => 'required|array',
]);
$order = $service->handle($validated);
return response()->json($order);
}
}
PHPImprovement:
- Controller is now thin
- Logic is reusable
- Easier to test
But we’re not done yet.
Step 2: Introduce a Data Transfer Object (DTO)
Passing raw arrays is fragile.
Let’s fix that.
class CreateOrderData
{
public function __construct(
public int $userId,
public array $items
) {}
public static function fromArray(array $data): self
{
return new self(
$data['user_id'],
$data['items']
);
}
}
PHPUpdate controller:
$data = CreateOrderData::fromArray($validated);
$order = $service->handle($data);
PHPUpdate service:
public function handle(CreateOrderData $data): Order
PHPImprovement:
- Stronger typing
- Safer refactoring
- Clear contract
Step 3: Decouple External Dependencies
Right now, your service is tightly coupled to:
- Eloquent models
- HTTP client
Let’s extract the webhook logic.
class OrderWebhookService
{
public function send(Order $order): void
{
Http::post('https://example.com/webhook', [
'order_id' => $order->id,
]);
}
}
PHPInject it:
class CreateOrderService
{
public function __construct(
private OrderWebhookService $webhook
) {}
public function handle(CreateOrderData $data): Order
{
// logic...
$this->webhook->send($order);
return $order;
}
}
PHPImprovement:
- External side effects are isolated
- Easier to mock in tests
Step 4: Make It Testable
Now you can test the service independently:
public function test_it_creates_order()
{
$service = app(CreateOrderService::class);
$data = new CreateOrderData(
userId: 1,
items: [
['id' => 1, 'quantity' => 2],
]
);
$order = $service->handle($data);
$this->assertNotNull($order->id);
}
PHPBefore refactoring, this would require:
- HTTP mocking
- Controller testing
- Complex setup
Now it’s isolated.
Step 5: Optional — Introduce Repositories (Only If Needed)
Don’t overengineer this.
But if your app grows, you might extract:
class ProductRepository
{
public function find(int $id): ?Product
{
return Product::find($id);
}
}
PHPThen inject it into the service.
When to do this:
- Multiple data sources
- Complex queries
- Domain logic reuse
When NOT to:
- Simple CRUD apps
Before vs After
| Aspect | Before | After |
|---|---|---|
| Controller size | Huge | Minimal |
| Testability | Hard | Easy |
| Reusability | None | High |
| Coupling | High | Reduced |
| Maintainability | Painful | Scalable |
Common Mistakes to Avoid
1. Moving everything blindly into services
You’ll just create fat services instead of fat controllers.
Keep services focused.
2. Overengineering with too many layers
You don’t need:
- 10 interfaces
- 5 abstractions
- enterprise architecture™
Start simple. Evolve when needed.
3. Ignoring boundaries
Controllers = HTTP
Services = business logic
Models = persistence
Mixing these again = back to chaos.
Key Takeaways
- Fat controllers are a symptom, not the root problem
- The real issue is mixed responsibilities
- Start with service extraction
- Add DTOs for safety
- Isolate side effects (APIs, events)
- Only introduce more abstraction when justified
Final Thought
Clean Architecture in Laravel doesn’t mean rewriting your app into a textbook diagram.
It means one thing:
Making your code easier to change without fear.
And the fastest way to get there?
Start killing your fat controllers — one method at a time.
If this is something you’re dealing with right now, your next step is simple:
Pick your worst controller and extract just one action into a service.
That’s how clean architecture actually starts.

