Back to Blog
testingclean-architectureservice-layerrepositorylaravel

Thin Controllers, Better Tests: Service + Repository Pattern

May 14, 2025
Nurhuda Joantama
Thin Controllers, Better Tests: Service + Repository Pattern

Table of Contents

  • Why fat controllers started to hurt
  • The split that changed the shape of the code
  • What the code looked like before and after
  • What changed after the refactor
  • How I think about the pattern now
  • Trade-offs and where to keep logic in the controller
  • Final thoughts

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

I spent months fighting fat Laravel controllers.

They mixed duplicate queries, view code, business rules, and data access in the same place. Tests were painful to write, mocks were messy, and a one-line tweak once sent side effects through half the app in production.

That was the moment I split the mess into a service and repository layer.

1 · Why fat controllers started to hurt

The problem was not just size. It was the way everything blended together.

  • Controllers returned JSON and talked to Eloquent directly.
  • No single class owned the rules.
  • Mocking direct model calls inside controller tests was a chore.
  • Bugs slipped through review and only showed up under real traffic.

It worked, but only in the way a pile of shortcuts works until it doesn’t.

2 · The split that changed the shape of the code

Once I admitted the controller should steer requests, not carry all the business logic, the boundaries became clearer.

  • Service: owns business rules and orchestration, and stays as close to plain PHP as possible.
  • Repository: hides data access, whether that means SQL, API calls, or cache reads, behind an interface.

This is not Laravel-only. The same shape shows up in Spring, Rails, Express with TypeDI, and plenty of other stacks.

3 · What the code looked like before and after

Fat controller, before the 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']); } }

That version was hard to mock and impossible to unit test in isolation.

Slim controller, service, and 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(); }

That unit test runs in under 50 ms and touches no real database.

4 · What changed after the refactor

The differences were pretty obvious:

  • Coverage: from around 0 percent to 75 percent and up.
  • Bugs escaping to production: from frequent to rare.
  • Build time: from slow, seed-heavy runs to faster tests with in-memory mocks.
  • WTFs per minute: still not zero, but much more manageable.

Repositories and fakes also removed heavy DB queries from CI, so the tests that used to crawl started to move much faster.

5 · How I think about the pattern now

The pattern is really about keeping each layer honest.

  • Laravel IoC can auto-resolve interfaces through AppServiceProvider bindings.
  • Spring uses @Service, @Repository, and @Autowired.
  • Node stacks often register classes in a container and resolve them in the controller.

The details change, but the idea stays the same, swap real repositories for stubs in tests.

6 · Trade-offs and where to keep logic in the controller

The split is not free.

  • More files can feel verbose in tiny apps.
  • Indirection adds a few more clicks for newcomers.
  • The payoff is cleaner separation between web, domain, and data.

I still keep a little logic in controllers when it is only about presentation, route guards, or policy checks. Anything beyond that usually belongs in the service.

7 · Final thoughts

Patterns are guides, not dogma.

For my mid-size, messy project, the service and repository split brought real wins in testability and stability. If fat controllers or flaky tests are slowing you down, it is worth trying, in Laravel or any other stack.

Sources and further reading

  • Matthew Daly, Put your Laravel controllers on a diet
  • Mastering the Service-Repository Pattern in Laravel
  • Stack Overflow and Engineering.SE discussions on thin controllers