Back to Blog
testingclean-architectureservice-layerrepositorylaravel

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

May 14, 2025
Nurhuda Joantama
Thin Controllers, Happy Tests – My Aha-Moment With the Service + Repository Pattern

Table of contents

“A 300-line controller just works… until the day it doesn’t.”

I spent months fighting “fat” Laravel controllers: duplicate queries, mixed-in view code, tricky mocks, zero tests.
The breaking point came when a one-line tweak nuked half the app in prod—because nothing caught the side-effects.

That night I split the mess into a Service + Repository layer. Test coverage soared, bugs plummeted, and onboarding new devs stopped feeling like hazing. Here’s the journey.


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])