Repository pattern in Laravel — when it helps and when it doesn't
Concept
Service classes extract complex business logic from controllers and models into dedicated, testable, single-responsibility classes. They're the backbone of maintainable Laravel applications.
Why services: Controllers should orchestrate — validate input, call services, return responses. They shouldn't contain business logic. Models should be data representations with relationships. When business logic lives in controllers or models, it becomes hard to test, reuse, and reason about.
What a service contains: Domain operations — "create an order", "cancel a subscription", "calculate pricing". It coordinates models, external APIs, events, notifications, and other services.
When to extract to a service:
- Logic needed in multiple places (controllers, commands, jobs).
- Logic involving multiple models or external calls.
- Logic with multiple code paths (conditionals, branching).
- Anything you want to unit-test without the HTTP layer.
Constructor injection: Services should declare their dependencies in the constructor. Laravel's container resolves them automatically when the service is type-hinted in a controller.
Return types: Services return domain objects, not HTTP responses. The controller converts the domain result to an HTTP response.
Exceptions as flow control: Services throw domain exceptions (InsufficientStockException, SubscriptionExpiredException). Controllers catch them and return appropriate HTTP responses.
Code Example
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use App\Models\User;
use App\Events\OrderPlaced;
use App\Exceptions\InsufficientStockException;
class OrderService
{
public function __construct(
private readonly \App\Repositories\OrderRepository $orders,
private readonly \App\Services\PaymentService $payments,
private readonly \App\Services\InventoryService $inventory,
) {}
public function placeOrder(User $user, array $items): Order
{
// Validate stock first (throws InsufficientStockException if not enough)
foreach ($items as $item) {
$this->inventory->reserveStock($item['product_id'], $item['quantity']);
}
$total = $this->calculateTotal($items);
// Charge payment
$payment = $this->payments->charge($user->paymentMethod, $total);
// Persist the order
$order = $this->orders->create([
'user_id' => $user->id,
'total' => $total,
'payment_id' => $payment->id,
'status' => 'confirmed',
], $items);
// Dispatch domain event (listeners handle notifications, etc.)
OrderPlaced::dispatch($order);
return $order;
}
private function calculateTotal(array $items): int
{
return collect($items)->sum(fn($item) =>
Product::find($item['product_id'])->price * $item['quantity']
);
}
}
// Controller — thin, just orchestrates
class OrderController extends Controller
{
public function __construct(private readonly OrderService $orderService) {}
public function store(Request $request): \Illuminate\Http\JsonResponse
{
$validated = $request->validate(['items' => 'required|array|min:1']);
try {
$order = $this->orderService->placeOrder($request->user(), $validated['items']);
return response()->json(new \App\Http\Resources\OrderResource($order), 201);
} catch (\App\Exceptions\InsufficientStockException $e) {
return response()->json(['error' => $e->getMessage()], 422);
} catch (\App\Exceptions\PaymentFailedException $e) {
return response()->json(['error' => 'Payment failed'], 402);
}
}
}