Object-Oriented PHP for Real Applications
Object-oriented code is useful when state and behavior belong together. The goal is not to turn every value into a class; it is to create clear, enforceable boundaries.
A Small Domain Object
<?php
declare(strict_types=1);
final class Invoice
{
/** @var list<InvoiceLine> */
private array $lines = [];
public function __construct(
private readonly int $id,
private string $status = 'draft'
) {
}
public function addLine(InvoiceLine $line): void
{
if ($this->status !== 'draft') {
throw new DomainException('Only draft invoices may be changed.');
}
$this->lines[] = $line;
}
public function totalCents(): int
{
return array_sum(array_map(
static fn (InvoiceLine $line): int => $line->totalCents(),
$this->lines
));
}
}
final readonly class InvoiceLine
{
public function __construct(
public string $description,
public int $quantity,
public int $unitPriceCents
) {
if ($quantity < 1 || $unitPriceCents < 0) {
throw new InvalidArgumentException('Invalid invoice line.');
}
}
public function totalCents(): int
{
return $this->quantity * $this->unitPriceCents;
}
}
Composition Over Inheritance
Inheritance creates a permanent relationship between classes. Composition is often more flexible: inject a mailer, clock, repository, or pricing policy into the service that needs it.
<?php
declare(strict_types=1);
interface Clock
{
public function now(): DateTimeImmutable;
}
final class SystemClock implements Clock
{
public function now(): DateTimeImmutable
{
return new DateTimeImmutable('now', new DateTimeZone('UTC'));
}
}
Dependency Injection Without a Container
<?php declare(strict_types=1); $pdo = Database::connect(); $customers = new PdoCustomerRepository($pdo); $auditLog = new PdoAuditLog($pdo); $suspendCustomer = new SuspendCustomer($customers, $auditLog); $controller = new CustomerController($suspendCustomer);
A dependency-injection container can automate object construction later. Starting with explicit construction keeps the object graph understandable.
Interfaces at Real Boundaries
Interfaces are valuable where implementations may genuinely vary or where isolation improves testing: payment gateways, mail delivery, object storage, clocks, queues, and repositories. An interface with one implementation and no testing or substitution need may only add noise.
Value Objects
<?php
declare(strict_types=1);
final readonly class EmailAddress
{
public string $value;
public function __construct(string $value)
{
$value = mb_strtolower(trim($value));
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException('Invalid email address.');
}
$this->value = $value;
}
}
Once constructed, a value object guarantees that its value is valid. That eliminates repeated validation throughout the application.