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:
| Layer | Job |
|---|---|
| Service | Own business rules; orchestration; pure PHP if possible. |
| Repository | Hide 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)
phpclass 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?
| Metric | Before | After |
|---|---|---|
| Coverage | ~ 0 % | 75 % + |
| Bugs escaping to prod | Frequent | Rare |
| Build time | Long (DB seeds) | Fast (in-memory mocks) |
| WTFs/minute ¹ | High | Manageable |
¹ 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
AppServiceProviderbindings. - 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
| Context | How it lives |
|---|---|
| Monolith | Simple: each module gets its own Service + Repository. |
| Micro-service | Service layer often is the API surface; repository hides DB vs. API calls. |
| Feature growth | New business rule? Extend or compose services; avoid dumping logic back in controllers. |
8 · Quick checklist for a new service
- Create a DTO for inputs (so tests don’t depend on
Request). - Inject repositories via the container.
- Throw domain exceptions (
OutOfStock) instead of returning error codes. - Keep the controller to “validate, call service, return response.”
- 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])