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.
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.
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.
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.
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.
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.
| Processing | Before | After | Criterion applied |
|---|---|---|---|
| Request shape check | Written inline in the controller | StoreOrderRequest | Chapter 12 |
| Calculating the total | A loop inside the controller | An accessor on the Order model | Chapter 9 |
| Checking stock | A loop inside the controller | StockChecker | Chapter 10 |
| Coordinating order creation and mail | Written inline in the controller | OrderPlacementService | Chapter 10 |
| Reporting insufficient stock | Returning an HTTP response directly | InsufficientStockException | Chapter 12 |
The controller left at the end looks like this.
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
What to read next
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 withProduct::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)