Skip to main content

Practice — refactoring class design

Chapter 1 showed a god class, and chapter 8 showed a Fat Controller and a Fat Model. From there through chapter 12 we built up, one at a time, the criteria for splitting responsibilities. This chapter refactors the chapter 8 Fat Controller from beginning to end, to see how those criteria connect. No new criterion appears. We simply apply the ones from the previous chapters in order.

Diagnosing the "before"​

Look once more at the Fat Controller 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);
}
}

Diagnosed with the yardstick from chapter 7, store() has low cohesion. Validation, calculating the total, checking stock, creating the order, and sending mail — processes that change for different reasons — all live together in one method. The reason the request format changes, the reason stock handling changes, and the reason the email copy changes have nothing to do with each other.

Step 1: sort out the model's responsibilities​

Chapter 9's criterion was "does this processing complete entirely within the representation and interpretation of the model's own data?" Following it, we move the total calculation into an accessor on the Order model.

✅ Good: the Order model from chapter 9
class Order extends Model
{
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}

protected function total(): Attribute
{
return Attribute::make(
get: fn (mixed $value, array $attributes) => $this->items
->sum(fn (OrderItem $item) => $item->price * $item->quantity),
);
}
}

Checking stock and sending mail do not fit inside the Order's own data representation, so they do not move into the model.

Step 2: extract the coordinating work into a service class​

The sequence of checking stock, creating the order, and sending mail is extracted into a service class, exactly as chapter 10's criterion says.

✅ Good: the OrderPlacementService from chapter 10
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;
}
}

OrderPlacementService concentrates on "coordinating the steps up to confirming an order," and StockChecker on "checking stock" — one concern each. In chapter 7's terms, splitting the classes raised the cohesion of both.

Step 3: receive dependencies by type​

Where OrderPlacementService depends on the concrete class StockChecker, Laravel's service container resolves it automatically from the constructor's type declaration alone, as chapter 11 showed. The same goes for OrderController receiving OrderPlacementService.

✅ Good: resolved from the type declaration alone
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}
}

Neither OrderPlacementService nor StockChecker is assembled directly with new; both are handed over from the constructor's type declaration alone. That comes from the mechanism seen in chapter 11, where Laravel's service container automatically resolves dependencies between concrete classes. StockChecker is still a concrete class, so if you want to be able to swap the implementation, you have to switch to depending through an interface, as chapters 6 and 11 showed.

Step 4: sort out validation and exceptions​

Finally, following chapter 12's criterion, we extract the request shape check into a Form Request and the insufficient-stock signal into a custom exception.

✅ Good: StoreOrderRequest and InsufficientStockException from chapter 12
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'items' => ['required', 'array'],
'items.*.name' => ['required', 'string'],
'items.*.price' => ['required', 'integer', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'customer_email' => ['required', 'email'],
];
}
}

class InsufficientStockException extends RuntimeException
{
}

Conversion from exception to HTTP response is already registered in withExceptions() in bootstrap/app.php, so the controller does not need to write try-catch.

The "after" in full​

Here is where each piece of processing ended up over the four steps.

ProcessingBeforeAfterCriterion applied
Request shape checkWritten inline in the controllerStoreOrderRequestChapter 12
Calculating the totalA loop inside the controllerAn accessor on the Order modelChapter 9
Checking stockA loop inside the controllerStockCheckerChapter 10
Coordinating order creation and mailWritten inline in the controllerOrderPlacementServiceChapter 10
Reporting insufficient stockReturning an HTTP response directlyInsufficientStockExceptionChapter 12

The controller left at the end looks like this.

✅ Good: the slim controller after
class OrderController extends Controller
{
public function __construct(
private readonly OrderPlacementService $orderPlacementService,
) {
}

public function store(StoreOrderRequest $request)
{
$order = $this->orderPlacementService->placeOrder(
$request->validated()['items'],
$request->validated()['customer_email'],
);

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

The "before" store() took 27 lines to handle five concerns; the "after" store() handles a single concern: hand the data validated by the Form Request to the service class, and turn the result into a response. The five concerns did not disappear — each moved to a place that suits it.

Summary​

  • A Fat Controller can be diagnosed as low cohesion: processes with different reasons to change living together
  • Applying four criteria in order — the model's responsibilities (chapter 9), extraction into a service class (chapter 10), dependency injection (chapter 11), and exceptions and validation (chapter 12) — leaves the controller a thin class handling one concern
  • Refactoring is not about deleting processing; it is about choosing a better home for each piece of it

The next chapter is the summary of the series. We look at a checklist for making your own class design calls at work.

Exercises​

Look at the controller below (before any service class extraction), list the five concerns, and say which class each should move to, with the chapter it comes from
class ReviewController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|integer',
'rating' => 'required|integer|min:1|max:5',
'comment' => 'required|string|max:1000',
]);

$product = Product::find($validated['product_id']);
if ($product === null) {
return response()->json(['error' => 'Product not found'], 404);
}

$review = Review::create($validated);

$averageRating = Review::where('product_id', $validated['product_id'])->avg('rating');
$product->update(['average_rating' => $averageRating]);

Mail::raw('A new review has been posted', function ($message) {
$message->to('shop-owner@example.com')->subject('New review');
});

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

Sample answer

  • Request shape check (required, min, max) → a Form Request (chapter 12)
  • Checking the product exists → a custom exception (say, ProductNotFoundException, chapter 12). Alternatively, leave it to Laravel's standard exception with Product::findOrFail()
  • Recalculating the average rating → it has the side effect of writing the recalculated average back to Product ($product->update([...])), so it does not stay within one model's data interpretation. It goes into a service class as coordinating work (chapter 10)
  • The sequence of creating the review, updating the average rating, and sending mail → a service class (say, ReviewService, chapter 10)
  • Handing over the service class → type-declaring it in the controller's constructor is enough (chapter 11)