Skip to main content

Splitting responsibilities into service classes — extraction that stops controllers growing

Chapter 9 narrowed the Eloquent model's responsibility down to "representing and interpreting the data it stands for." The criterion was that processing which coordinates several concerns, such as checking stock or sending mail, moves outside the model. So where does the processing you moved out actually go? This chapter looks at how to decide when to extract the coordinating work left in a controller into a service class.

The coordinating work left in the controller​

Look again at the Fat Controller example from chapter 8.

❌ Bad: a Fat Controller (repeated from chapter 8)
class OrderController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);

$total = 0;
foreach ($validated['items'] as $item) {
$total += $item['price'] * $item['quantity'];
}

foreach ($validated['items'] as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
return response()->json(['error' => 'Not enough stock'], 422);
}
}

$order = Order::create([
'customer_email' => $validated['customer_email'],
'total' => $total,
]);

Mail::raw("Thank you for your order. Your total is {$total} yen.", function ($message) use ($validated) {
$message->to($validated['customer_email'])->subject('Order confirmation');
});

return response()->json($order, 201);
}
}

Sorted against chapter 9's criterion, the contents of store() break down like this.

  • Validating the request: the controller's responsibility (or a Form Request's)
  • Calculating the total: expressible as an accessor on the Order model (chapter 9)
  • Checking stock, creating the order, sending mail: processing that coordinates several concerns

Those last three do not fit inside the Order model's data representation. Checking stock is a separate concern (stock), sending mail is another separate concern (notification), and creating the order has to run together with both as one sequence of steps. The place for that "coordination of a sequence of steps" is a service class.

Extracting into a service class​

Laravel has no official service class mechanism1. It is a naming convention, widely used in the community, for extracting processing that coordinates several concerns into a single class.

✅ Good: extracting into a service class
class OrderPlacementService
{
public function __construct(
private readonly StockChecker $stockChecker,
) {
}

public function placeOrder(array $items, string $customerEmail): Order
{
if (! $this->stockChecker->hasEnoughStock($items)) {
throw new RuntimeException('Not enough stock');
}

$order = Order::create([
'customer_email' => $customerEmail,
]);

foreach ($items as $item) {
$order->items()->create($item);
}

Mail::raw("Thank you for your order. Your total is {$order->total} yen.", function ($message) use ($customerEmail) {
$message->to($customerEmail)->subject('Order confirmation');
});

return $order;
}
}

class StockChecker
{
public function hasEnoughStock(array $items): bool
{
foreach ($items as $item) {
$stock = DB::table('stocks')->where('name', $item['name'])->value('quantity');
if ($stock < $item['quantity']) {
return false;
}
}
return true;
}
}

The controller is left simply handing the validation result to OrderPlacementService.

✅ Good: the slimmed-down controller
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderService,
) {
}

public function store(Request $request)
{
$validated = $request->validate([
'items' => 'required|array',
'customer_email' => 'required|email',
]);

try {
$order = $this->orderService->placeOrder($validated['items'], $validated['customer_email']);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}

return response()->json($order, 201);
}
}

The controller catches the exception thrown by OrderPlacementService and converts it into an HTTP response. How to report errors with exceptions is covered in chapter 12, so for now just look at the division of roles.

OrderPlacementService coordinates the sequence of checking stock, creating the order, and sending mail, while StockChecker concentrates on the single concern of checking stock. This is the same idea as the split in chapter 4, where StockChecker and OrderMailer were extracted from the Order class2. The StockChecker here takes the array that comes out of Laravel's validation directly, rather than an Order instance as in chapter 4. The details of how it is received in the constructor are covered in chapter 11.

Keeping cohesion — so it is not just a file split​

Extracting into a service class is not about splitting files for their own sake. Look at the example below.

❌ Bad: a service class that has absorbed unrelated processing
class OrderPlacementService
{
public function placeOrder(array $items, string $customerEmail): Order
{
// Order creation
}

public function generateMonthlySalesReport(): array
{
// Monthly sales report aggregation
}

public function suspendCustomer(string $email): void
{
// Suspending the account of a problem customer
}
}

placeOrder(), generateMonthlySalesReport(), and suspendCustomer() each change for a different reason. The reason the order flow changes, the reason the sales aggregation spec changes, and the reason the suspension policy changes have nothing to do with each other3. The processing was merely evicted from OrderController; the structure of packing several concerns into one class is unchanged. This is the same problem as the god class from chapter 1.

Service classes are not exempt from the single responsibility principle or the cohesion thinking from chapters 4 and 7. Narrow OrderPlacementService to the single concern of "coordinating the sequence of steps up to confirming an order," and put sales aggregation and account management in separate service classes.

Service classes versus the use case layer​

The OrderPlacementService here stays at the class design level: it gathers the steps of checking stock, creating the order, and sending mail into one method to split up a controller that had grown too big.

A Practical Guide to Laravel × DDD × Clean Architecture, by contrast, calls a class with a similar role a "use case" and designs it to include managing transaction boundaries and publishing domain events. Because this guide stays within class design, it does not step into that architectural design. If you want to learn application layer design properly, see the relevant chapters of that guide.

Summary​

  • Processing left in the controller that coordinates several concerns goes beyond the model's responsibility
  • Extract the coordinating work into a service class. This is not an official Laravel feature but a widely used community naming convention
  • Extracting into a service class is not about splitting files; keep single responsibility and cohesion after the split too
  • Application layer design such as managing transaction boundaries or publishing domain events is outside this guide's scope

The next chapter covers how to hand a service class to a controller. We look at dependency injection and the basic use of the Laravel container.

Exercises​

Is the following service class well designed? Answer with your reasoning
class UserService
{
public function register(string $email, string $password): User { /* ... */ }
public function login(string $email, string $password): bool { /* ... */ }
public function exportUsersToCsv(): string { /* ... */ }
}

Sample answer

register() and login() both belong to the concern of user authentication, and tend to change for the same reason. exportUsersToCsv(), on the other hand, is a separate concern — report output — and a change to the CSV format has no bearing on the authentication spec. Moving exportUsersToCsv() into a separate service class (say, UserExportService) keeps cohesion higher.


Footnotes​

  1. Source: Laravel Service Classes Explained (DEV Community). It explains that service classes are not built into the Laravel framework and are not defined in the official documentation; they are a design pattern widely used in the community. ↩

  2. See the split into StockChecker and OrderMailer in chapter 4. ↩

  3. Source: the single responsibility principle covered in chapter 4 (source: Single responsibility principle, Robert C. Martin, 2003). ↩