Object-Oriented PHP for Real Applications
I like objects when data and the rules about that data genuinely belong together. What I don't like is turning everything into a class because that's what "real" programmers supposedly do. The point of OOP is clear boundaries you can enforce, not a folder full of AbstractFactoryManagers.
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 welds two classes together for good. Composition is usually more flexible: hand the service the mailer, clock, repository, or pricing policy it needs, and swap them out whenever you want.
<?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);
You can bring in a DI container later to wire everything up automatically. Starting out by building objects by hand keeps it obvious what depends on what, and honestly, a lot of apps never need more than that.
Interfaces at real boundaries
Interfaces earn their keep where the implementation really might change, or where faking it makes testing easier: payment gateways, mail delivery, object storage, clocks, queues, repositories. An interface with exactly one implementation that you never swap or fake is just one more file to open.
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 a value object exists, you know it's valid. That means you can stop re-checking the same email address in five different places. This is probably my favorite OOP habit.
