2025-05-14

Thin Controllers, Happy Tests – My Aha-Moment With the Service + Repository Pattern

testingclean-architectureservice-layerrepositorylaravel
Thin Controllers, Happy Tests – My Aha-Moment With the Service + Repository Pattern

Thin Controllers, Happy Tests – My Aha-Moment With the Service + Repository Pattern

Table of contents

1 · Pain before the refactor

  • Fat controllers returned JSON and talked to Eloquent directly. No single class owned the rules.
  • Zero unit tests — mocking a direct Product::find() inside a controller was torture.
  • Hidden bugs sailed past code review; they only surfaced under real traffic (classic “works on my machine”).

Plenty of folks call this a code smell


2 · The “split it!” epiphany

After one sprint full of regressions, I admitted defeat:

Controllers should steer requests, not calculate business logic.
So I carved two clear boundaries:

LayerJob
ServiceOwn business rules; orchestration; pure PHP if possible.
RepositoryHide data access (SQL, API, cache) behind an interface.

The concept isn’t Laravel-only—you’ll find the same shape in Spring, Rails, Express + TypeDI—patterns, not frameworks


3 · Code before vs. after

3.1 — “Fat” controller (pre-refactor)

php
class OrderController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'product_id'=>'required|int',
            'qty'=>'required|int|min:1'
        ]);

        // business + persistence + response = 💥
        $product = Product::findOrFail($data['product_id']);

        if ($product->stock < $data['qty']) {
            return response()->json(['error'=>'Out of stock'], 422);
        }

        DB::transaction(function () use ($data, $product) {
            Order::create([
                'product_id' => $product->id,
                'qty'        => $data['qty'],
                'amount'     => $product->price * $data['qty'],
            ]);

            $product->decrement('stock', $data['qty']);
        });

        return response()->json(['status'=>'ok']);
    }
}

Hard to mock, impossible to unit-test in isolation.


3.2 — Slim controller + Service + Repository

php
// routes/web.php
Route::post('/orders', OrderStoreController::class);

// app/Http/Controllers/OrderStoreController.php
class OrderStoreController
{
    public function __construct(private PlaceOrderService $service) {}

    public function __invoke(OrderRequest $request)
    {
        $this->service->execute(
            new PlaceOrderDto(
                productId: $request->input('product_id'),
                qty:       $request->input('qty')
            )
        );

        return response()->json(['status'=>'ok']);
    }
}
php
// app/Services/PlaceOrderService.php
class PlaceOrderService
{
    public function __construct(
        private ProductRepository $products,
        private OrderRepository   $orders,
    ) {}

    public function execute(PlaceOrderDto $dto): void
    {
        $product = $this->products->getById($dto->productId);

        if ($product->stock < $dto->qty) {
            throw OutOfStock::forProduct($product->id);
        }

        DB::transaction(function () use ($product, $dto) {
            $this->orders->create(
                productId: $product->id,
                qty:       $dto->qty,
                amount:    $product->price * $dto->qty
            );
            $this->products->decreaseStock($product->id, $dto->qty);
        });
    }
}
php
// tests/Unit/Services/PlaceOrderServiceTest.php
public function test_execute_places_order_when_stock_available()
{
    $products = Mockery::mock(ProductRepository::class);
    $orders   = Mockery::spy(OrderRepository::class);

    $service  = new PlaceOrderService($products, $orders);

    $products->shouldReceive('getById')
             ->once()
             ->andReturn((object)['id'=>1,'price'=>10_000,'stock'=>5]);

    $service->execute(new PlaceOrderDto(productId:1, qty:2));

    $orders->shouldHaveReceived('create')->once();
}

Unit test runs in < 50 ms, touches no real database.


4 · What changed after the split?

MetricBeforeAfter
Coverage~ 0 %75 % +
Bugs escaping to prodFrequentRare
Build timeLong (DB seeds)Fast (in-memory mocks)
WTFs/minute ¹HighManageable

¹ Borrowed from the classic dev-experience metric.

Repositories & fakes remove heavy DB queries from CI—tests that used to crawl now fly ([Laravel][1]).


5 · Dependency Injection cheatsheet

  • Laravel IoC – auto-resolves interfaces via AppServiceProvider bindings.
  • Spring – annotate @Service, @Repository, inject with @Autowired.
  • Node (awilix / tsyringe) – register classes and resolve() in the controller.

Whatever the container, the point is the same: swap real repositories for stubs in tests.


6 · Trade-offs & doubts

  • More files – services, DTOs, interfaces… can feel verbose in tiny apps.
  • Indirection – newcomers click more times to trace a request.
  • Independence – but those extra layers decouple web, domain, and data—worth it for anything beyond a toy project.

7 · Scaling the pattern

ContextHow it lives
MonolithSimple: each module gets its own Service + Repository.
Micro-serviceService layer often is the API surface; repository hides DB vs. API calls.
Feature growthNew business rule? Extend or compose services; avoid dumping logic back in controllers.

8 · Quick checklist for a new service

  1. Create a DTO for inputs (so tests don’t depend on Request).
  2. Inject repositories via the container.
  3. Throw domain exceptions (OutOfStock) instead of returning error codes.
  4. Keep the controller to “validate, call service, return response.”
  5. Write a happy-path unit test first.

9 · When a tiny bit of logic is fine in a controller

  • Pure presentation: mapping domain object → JSON/Blade.
  • Route guards or policy checks. Anything else? Push it into the service.

Final thoughts

Patterns are just guides, not dogma. For my mid-size, messy project, the Service + Repository split brought measurable wins in testability and stability. If fat controllers or flaky tests haunt your codebase, give it a try—Laravel or otherwise.


Sources & further reading

  • Matthew Daly – Put your Laravel controllers on a diet ([Matthew Daly][4])
  • Mastering the Service-Repository Pattern in Laravel ([Medium][2])
  • Stack Overflow & Engineering.SE discussions on thin controllers ([Stack Overflow][5], [Software Engineering Stack Exchange][6])

Let’s Collaborate with me!

Ready to start your next project? Hit me up!

Contact Me