Fat Controller Laravel Refactor: Step-by-Step Clean Architecture Guide

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);
    }
}
PHP

What’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;
    }
}
PHP

Controller 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);
    }
}
PHP

Improvement:

  • 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']
        );
    }
}
PHP

Update controller:

$data = CreateOrderData::fromArray($validated);
$order = $service->handle($data);
PHP

Update service:

public function handle(CreateOrderData $data): Order
PHP

Improvement:

  • 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,
        ]);
    }
}
PHP

Inject it:

class CreateOrderService
{
    public function __construct(
        private OrderWebhookService $webhook
    ) {}

    public function handle(CreateOrderData $data): Order
    {
        // logic...

        $this->webhook->send($order);

        return $order;
    }
}
PHP

Improvement:

  • 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);
}
PHP

Before 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);
    }
}
PHP

Then 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

AspectBeforeAfter
Controller sizeHugeMinimal
TestabilityHardEasy
ReusabilityNoneHigh
CouplingHighReduced
MaintainabilityPainfulScalable

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.

Leave a Reply

Your email address will not be published. Required fields are marked *