2025-05-14
Thin Controllers, Happy Tests – My Aha-Moment With the Service + Repository Pattern
Product::find()
inside a controller was torture.Plenty of folks call this a code smell
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
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.
// 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']);
}
}
// 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);
});
}
}
// 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.
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]).
AppServiceProvider
bindings.@Service
, @Repository
, inject with @Autowired
.resolve()
in the controller.Whatever the container, the point is the same: swap real repositories for stubs in tests.
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. |
Request
).OutOfStock
) instead of returning error codes.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.