Skip to main content

Introduction — why learn class design

Being able to write a class in PHP and being able to design a class that holds up under change are two different skills.

Even a class written correctly against the syntax can force you to reread code all over the place every time you add a feature, or let a single fix quietly change behavior somewhere you did not expect. This series is written to close that gap.

What happens when you pack responsibilities into one class​

Look at the Order class below. It represents an order, the kind you see all the time on an e-commerce site.

Class syntax is covered in chapter 2

You do not need to worry yet about syntax details like private or public function. Chapter 2 works through each of them. For now, just look at the overall shape: how many methods (blocks of processing defined inside a class) there are, and what each one does. Database::query and Database::insert are fictional classes made up for the sake of the example.

❌ Bad: an Order class with several responsibilities
<?php

class Order
{
private array $items = [];
private string $customerEmail;

public function __construct(string $customerEmail)
{
$this->customerEmail = $customerEmail;
}

public function addItem(string $name, int $price, int $quantity): void
{
$this->items[] = [
'name' => $name,
'price' => $price,
'quantity' => $quantity,
];
}

public function calculateTotal(): int
{
$total = 0;
foreach ($this->items as $item) {
$total += $item['price'] * $item['quantity'];
}
return $total;
}

public function hasEnoughStock(): bool
{
foreach ($this->items as $item) {
$stock = Database::query(
'SELECT quantity FROM stocks WHERE name = ?',
[$item['name']]
);
if ($stock < $item['quantity']) {
return false;
}
}
return true;
}

public function sendConfirmationEmail(): void
{
$body = "Thank you for your order. Your total is {$this->calculateTotal()} yen.";
mail($this->customerEmail, 'Order confirmation', $body);
}

public function save(): void
{
Database::insert('orders', [
'customer_email' => $this->customerEmail,
'total' => $this->calculateTotal(),
]);
}

public function generateInvoicePdf(): string
{
$total = $this->calculateTotal();
// PDF generation (omitted; $total goes into the invoice total field)
return "invoice-{$this->customerEmail}.pdf";
}
}

This class single-handedly takes on six roles: managing order lines, calculating the total, checking stock, sending the confirmation email, saving to the database, and generating the invoice PDF.

A class that has grown past its own role and absorbed many responsibilities is called a god class1. A god class is not a PHP syntax error in itself. It runs, and if you feed it test data it returns the right total. The problem is not whether it runs; it surfaces later, as changes pile up.

A change does not stay in one place​

Suppose the following request comes in for the Order class.

For deliveries to remote islands, add a shipping fee.

To implement it, you first have to work out where to make the change. Do you add the shipping fee inside calculateTotal, or add a separate method that returns just the fee? If you pick the former, you have to find every place that reads the return value of calculateTotal. sendConfirmationEmail embeds the total in the email body, save writes that value to the database, and generateInvoicePdf puts it on the invoice.

Because three processes with quite different natures — sending mail, saving, and generating a PDF — all share the return value of calculateTotal, changing the calculation logic means checking that the amount is right in all three places. The more callers there are, the more of this checking you have to do.

hasEnoughStock has the same problem. It fires a query straight at the Database class, so when the structure of the stock table changes, or when you want to reuse the stock check on another screen, you are forced either to call this method from outside the Order class or to copy the same query somewhere else.

What changes once you learn class design​

The same order processing looks quite different once you split it by responsibility. Order lines and amount calculation stay in the Order class; the stock check goes to StockChecker, sending mail to OrderMailer, and PDF generation to InvoicePdfGenerator, so that each one holds a single role.

When you want to change the shipping fee rules, you only need to look at the class that calculates the fee. When you want to reuse the stock check logic on another screen, calling StockChecker is all it takes.

This splitting is not a matter of adding classes at random. Split it wrong and the interactions between classes get complicated instead, producing a different kind of unreadability from the god class. Building the judgment for where to split and where to leave things alone is what the following chapters do, one step at a time.

What this series covers​

The series comes in two parts. Part 1 (chapters 1 to 7) covers PHP class design principles that do not depend on any framework, and Part 2 (chapters 8 to 14) applies those principles to model design and service class design in Laravel.

No prior experience with class design is assumed. If you have the basics of PHP syntax (variables, functions, control flow), you can start reading from Part 1. By the end of the series, the goal is to be able to look at a design breakdown like a Fat Controller or a Fat Model and judge, in your own words, what is wrong and how to fix it. The full chapter outline and a detailed description of the intended reader are in the guide's table of contents.

Exercises​

Work out the blast radius of adding shipping fee calculation to the Order class

Suppose you add shipping fee calculation to calculateTotal. List every method in the Order class from the text above that you would need to check still behaves correctly.

Sample answer

You need to check the following three methods, all of which read the return value of calculateTotal.

  • sendConfirmationEmail: does the total in the email body include the shipping fee?
  • save: does the total written to the database include the shipping fee?
  • generateInvoicePdf: does the invoice total include the shipping fee?

Because all three methods use the return value of calculateTotal as is, changing the calculation logic affects all three places. How to reduce this dependency is covered in the chapters that follow.

Summary​

  • Packing loosely related responsibilities into one class produces what is called a god class
  • A god class runs, but a single change tends to ripple out to several callers, so every change requires working out the blast radius
  • Splitting by responsibility narrows the blast radius of a change
  • The judgment for where to split is built up step by step over the following chapters

The next chapter covers the basic syntax of classes in PHP: properties, methods, constructors, visibility modifiers, readonly, constructor promotion, and enum.


Footnotes​

  1. Source: What Is a God Class and Why Should We Avoid It?. An anti-pattern in which a single class takes on excessive responsibility and ends up knowing about and controlling most of the system. It is a textbook violation of the single responsibility principle (covered in chapter 4). ↩