DEV Community

Cover image for Fat Controller to Clean Architecture in Laravel (Step-by-Step Refactor)
CodeCraft Diary
CodeCraft Diary

Posted on • Originally published at codecraftdiary.com

Fat Controller to Clean Architecture in Laravel (Step-by-Step Refactor)

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.


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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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']
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Update controller:

$data = CreateOrderData::fromArray($validated);
$order = $service->handle($data);
Enter fullscreen mode Exit fullscreen mode

Update service:

public function handle(CreateOrderData $data): Order
Enter fullscreen mode Exit fullscreen mode

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,
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Inject it:

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

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

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

        return $order;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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

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.

Top comments (0)