Service — a class containing business logic that doesn't belong in the model
Concept
Service — a class containing business logic that doesn't naturally belong in a model, controller, or repository. It orchestrates operations, coordinates multiple domain objects, and encapsulates a specific business capability.
Why services exist: The classic MVC problem — controllers grow fat because they're the only place to put orchestration logic. Models should represent domain objects. Repositories handle data access. Where does the logic go? Services.
What a service does:
- Coordinates multiple models/repositories.
- Contains conditional business logic.
- Dispatches events.
- Calls external APIs (payment gateway, email service).
- Wraps multiple operations in a transaction.
Service vs Repository:
- Repository: Data access.
OrderRepository::findPendingOrders(). - Service: Business logic.
OrderService::cancelExpiredOrders()— which calls repositories, sends emails, dispatches events.
Service vs Action class: A service is more general — it might have multiple related methods (OrderService::create(), OrderService::cancel(), OrderService::ship()). An action is a single-purpose class for one operation.
Thin controllers: Controllers should be thin — validate input, call a service, return response. Business logic is in the service, not the controller.
Laravel service conventions:
- Single-responsibility services:
PaymentService,NotificationService,ReportingService. - Registered in the service container.
- Constructor-injected into controllers.
Code Example
<?php
// ❌ FAT CONTROLLER — business logic in the wrong place
class OrderController extends Controller
{
public function store(CreateOrderRequest $request)
{
// This all belongs in a service!
$order = \DB::transaction(function () use ($request) {
$order = Order::create($request->validated());
foreach ($request->items as $item) {
$product = Product::lockForUpdate()->find($item['product_id']);
if ($product->stock < $item['quantity']) throw new \DomainException('Insufficient stock');
$product->decrement('stock', $item['quantity']);
$order->items()->create($item);
}
return $order;
});
Mail::to($request->user())->send(new OrderConfirmation($order));
event(new OrderPlaced($order));
Cache::forget('user:' . $request->user()->id . ':order_count');
return response()->json($order, 201);
}
}
// ✅ THIN CONTROLLER + SERVICE
class OrderController extends Controller
{
public function __construct(private readonly OrderService $orders) {}
public function store(CreateOrderRequest $request): \Illuminate\Http\JsonResponse
{
$order = $this->orders->place($request->user(), $request->validated());
return response()->json($order, 201);
}
}
// ✅ SERVICE — all the orchestration logic
class OrderService
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
private readonly ProductRepositoryInterface $productRepository,
) {}
public function place(User $user, array $data): Order
{
return \DB::transaction(function () use ($user, $data) {
$order = Order::create(['user_id' => $user->id, 'status' => 'pending', 'total' => 0]);
$total = 0;
foreach ($data['items'] as $item) {
$product = $this->productRepository->findWithLock($item['product_id']);
if ($product->stock < $item['quantity']) {
throw new \App\Exceptions\InsufficientStockException($product);
}
$product->decrement('stock', $item['quantity']);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'price' => $product->price,
]);
$total += $product->price * $item['quantity'];
}
$order->update(['total' => $total]);
event(new \App\Events\OrderPlaced($order)); // dispatch event — listener sends email, etc.
return $order->fresh(['items']);
});
}
public function cancel(Order $order): void
{
if (!in_array($order->status, ['pending', 'confirmed'])) {
throw new \DomainException("Cannot cancel a {$order->status} order");
}
\DB::transaction(function () use ($order) {
foreach ($order->items as $item) {
Product::where('id', $item->product_id)->increment('stock', $item->quantity);
}
$order->update(['status' => 'cancelled', 'cancelled_at' => now()]);
event(new \App\Events\OrderCancelled($order));
});
}
}