SOLID Principles in PHP

Understand each of the five SOLID principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion — with a concrete before/after PHP example for each, so the principles read as practical design pressure rather than abstract rules.

SOLID is five separate design guidelines that share one goal: code that can change in one place without breaking somewhere else. Each letter names a specific failure mode — a class doing too much, code that can't be extended without editing it, a subclass that breaks its parent's promises, an interface that forces classes to implement methods they don't need, and business logic wired directly to low-level details. None of these are PHP-specific rules; they're general object-oriented design pressure that happens to map very cleanly onto PHP's interfaces, abstract classes, and typed constructors. This lesson works through all five with one small, realistic before/after case study each.

S — Single Responsibility Principle

A class should have one reason to change. This does not mean 'a class should do one tiny thing' — it means a class's responsibilities should not be so mixed that a change driven by one concern (how a receipt is formatted) forces edits to code also responsible for an unrelated concern (how an order total is calculated). When those two concerns live in the same class, every change to either one risks breaking the other, and the class accumulates reasons to change that have nothing to do with each other.

A common violation: an Order class that both computes totals and formats/prints a receipt. Splitting it into Order (data + total calculation) and a separate ReceiptFormatter (presentation) means a change to receipt layout can never accidentally break total calculation, and vice versa — each class now has exactly one axis along which it needs to change.

O — Open/Closed Principle

Software entities should be open for extension but closed for modification: adding new behavior should be possible by adding new code, not by editing existing, already-tested code. The classic violation is a long if/switch chain over a type discriminator that must be edited every time a new case is added — function calculateShipping(string $type) { if ($type === 'standard') {...} elseif ($type === 'express') {...} } requires touching this function (and re-testing everything in it) every single time a new shipping type is introduced.

The fix is polymorphism: define a ShippingStrategyInterface with a cost(Order $order): float method, one implementation class per shipping type, and code that depends only on the interface. Adding a new shipping type means writing a new class that implements the interface — the calling code and every existing shipping class remain completely untouched and don't need to be re-tested.

L — Liskov Substitution Principle

Any subtype should be usable anywhere its supertype is expected, without the caller needing to know or care which one it actually got. A subclass violates this when it narrows what the parent promises to accept, widens what it promises to return, or throws where the parent guaranteed it wouldn't — the caller's assumptions, built against the parent's contract, silently break.

The textbook example: class Rectangle { public function setWidth(float $w) {} public function setHeight(float $h) {} } and class Square extends Rectangle that overrides both setters to keep width and height equal (since a square must have equal sides). Code written against Rectangle that calls setWidth(5); setHeight(10); expecting a 5x10 rectangle silently gets a 10x10 square instead when handed a Square — the subclass looks correct in isolation but breaks every caller that relied on Rectangle's actual contract. The fix is not forcing the inheritance at all: Square and Rectangle should not be in an is-a relationship if their setter contracts genuinely can't agree; model them as separate types (both implementing a HasAreaInterface, say) instead.

I — Interface Segregation Principle

No class should be forced to depend on (implement) methods it doesn't use. A single bloated interface pushes every implementer to either genuinely support every method or implement stubs that throw or silently do nothing — both are worse than not having that method in the contract at all.

A WorkerInterface with work(), eat(), and sleep() forces a RobotWorker class to implement eat() and sleep() even though robots do neither — typically as a method that throws LogicException or just does nothing, which is a lie sitting in the type system (the interface claims robots can eat; they can't). Splitting into Workable, Eatable, and Sleepable — each implemented only by classes that genuinely have that capability — means RobotWorker implements Workable alone, with no misleading stub methods.

D — Dependency Inversion Principle

High-level modules (business logic) should not depend on low-level modules (concrete implementation details); both should depend on abstractions. This is the same principle covered in depth in the composition/DI lesson, restated as the fifth SOLID pillar because it's what makes the other four practically achievable — Open/Closed's polymorphism and Interface Segregation's small interfaces are only useful if the code consuming them depends on the interface rather than a specific concrete class.

A ReportService that does $db = new MySqlConnection(); inside its constructor is welded to MySQL specifically; a ReportService that receives a DatabaseInterface through its constructor can work against MySQL, PostgreSQL, or an in-memory test double without any change to ReportService itself.

SOLID at a glance

All five principles push in the same direction: smaller, more focused units (classes, interfaces) connected through abstractions rather than large units connected through concrete, editable implementation details.

// S: one class, one reason to change
class Order { public function total(): float { /* ... */ } }
class ReceiptFormatter { public function format(Order $o): string { /* ... */ } }

// O: extend via new classes, not by editing existing ones
interface ShippingStrategyInterface { public function cost(Order $o): float; }
class ExpressShipping implements ShippingStrategyInterface { public function cost(Order $o): float { return 15.0; } }

// L: a subtype must honor its supertype's contract fully
interface HasAreaInterface { public function area(): float; }
class Square implements HasAreaInterface { public function __construct(private float $side) {} public function area(): float { return $this->side ** 2; } }

// I: small, role-specific interfaces
interface Workable { public function work(): void; }
interface Eatable { public function eat(): void; }

// D: depend on the abstraction, not the concrete class
interface DatabaseInterface { public function query(string $sql): array; }
class ReportService { public function __construct(private DatabaseInterface $db) {} }

Code Examples

In the BEFORE version, any change to receipt formatting (a new layout, a new currency symbol) requires editing the same class responsible for calculating totals, risking an unrelated bug in total(). The AFTER version separates the two concerns so each class has exactly one reason to change. Expected result: Receipt: Book: $12 Total: $12

The BEFORE function must be edited (and re-tested end-to-end) every time a new shipping type is added. The AFTER version defines a stable interface; OvernightShipping was added purely by writing a new class, with calculateShippingAfter() and every prior shipping class left completely untouched. Expected result: 16.5

Square extends Rectangle breaks every caller that relies on Rectangle's actual setter contract (independent width/height), because a Square silently changes both dimensions together. The fix isn't a cleverer override — it's recognizing Square and Rectangle don't truly share an is-a contract, and giving them a narrower shared interface (area()) that both can honestly satisfy without surprises. Expected result: 50 16

RobotWorker in the BEFORE version is forced to implement eat()/sleep() it can never honestly perform, so those methods lie by throwing. Splitting WorkerInterface into Workable, Eatable, Sleepable lets RobotWorker2 implement only what it truthfully supports, and runShift() depends only on the one capability (Workable) it actually needs. Expected result: Robot working Human working

// BEFORE: violates Single Responsibility
class Order
{
    public array $items = [];

    public function total(): float
    {
        return array_sum(array_column($this->items, 'price'));
    }

    public function printReceipt(): string
    {
        $lines = ["Receipt:"];
        foreach ($this->items as $item) {
            $lines[] = "{$item['name']}: \${$item['price']}";
        }
        $lines[] = "Total: \${$this->total()}";
        return implode("\n", $lines);
    }
}

// AFTER: two classes, two responsibilities
class Order2
{
    public array $items = [];

    public function total(): float
    {
        return array_sum(array_column($this->items, 'price'));
    }
}

class ReceiptFormatter
{
    public function format(Order2 $order): string
    {
        $lines = ["Receipt:"];
        foreach ($order->items as $item) {
            $lines[] = "{$item['name']}: \${$item['price']}";
        }
        $lines[] = "Total: \${$order->total()}";
        return implode("\n", $lines);
    }
}

$order = new Order2();
$order->items = [['name' => 'Book', 'price' => 12.0]];
echo (new ReceiptFormatter())->format($order);
// BEFORE: violates Open/Closed -- new shipping type means editing this function
function calculateShippingBefore(string $type, float $weight): float
{
    if ($type === 'standard') {
        return 5.0;
    } elseif ($type === 'express') {
        return 15.0 + $weight * 0.5;
    }
    throw new InvalidArgumentException('unknown shipping type');
}

// AFTER: open for extension, closed for modification
interface ShippingStrategyInterface
{
    public function cost(float $weight): float;
}

final class StandardShipping implements ShippingStrategyInterface
{
    public function cost(float $weight): float { return 5.0; }
}

final class ExpressShipping implements ShippingStrategyInterface
{
    public function cost(float $weight): float { return 15.0 + $weight * 0.5; }
}

// Adding overnight shipping later requires zero edits here:
final class OvernightShipping implements ShippingStrategyInterface
{
    public function cost(float $weight): float { return 40.0 + $weight * 1.0; }
}

function calculateShippingAfter(ShippingStrategyInterface $strategy, float $weight): float
{
    return $strategy->cost($weight);
}

echo calculateShippingAfter(new ExpressShipping(), 3.0); // 16.5
// BEFORE: violates Liskov Substitution
class Rectangle
{
    protected float $width = 0;
    protected float $height = 0;
    public function setWidth(float $w): void { $this->width = $w; }
    public function setHeight(float $h): void { $this->height = $h; }
    public function area(): float { return $this->width * $this->height; }
}

class Square extends Rectangle
{
    public function setWidth(float $w): void { $this->width = $this->height = $w; }
    public function setHeight(float $h): void { $this->width = $this->height = $h; }
}

function resizeAndCheckBefore(Rectangle $r): void
{
    $r->setWidth(5);
    $r->setHeight(10);
    assert($r->area() === 50.0); // FAILS when $r is actually a Square (area becomes 100)
}

// AFTER: no forced is-a relationship; both share a narrower, honest contract
interface HasAreaInterface { public function area(): float; }

final class Rectangle2 implements HasAreaInterface
{
    public function __construct(private float $width, private float $height) {}
    public function area(): float { return $this->width * $this->height; }
}

final class Square2 implements HasAreaInterface
{
    public function __construct(private float $side) {}
    publi
…
// BEFORE: one fat interface forces irrelevant methods on implementers
interface WorkerInterface
{
    public function work(): void;
    public function eat(): void;
    public function sleep(): void;
}

final class RobotWorker implements WorkerInterface
{
    public function work(): void { echo "Robot working\n"; }
    public function eat(): void { throw new LogicException('robots do not eat'); } // lie in the type system
    public function sleep(): void { throw new LogicException('robots do not sleep'); }
}

// AFTER: small, role-specific interfaces
interface Workable { public function work(): void; }
interface Eatable { public function eat(): void; }
interface Sleepable { public function sleep(): void; }

final class HumanWorker implements Workable, Eatable, Sleepable
{
    public function work(): void { echo "Human working\n"; }
    public function eat(): void { echo "Human eating\n"; }
    public function sleep(): void { echo "Human sleeping\n"; }
}

final class RobotWorker2 implements Workable
{
    public function work(): void { echo "Robot working\n"; }
}

function runShift(Workable $w): void { $w->work(); }
runShift(new RobotWorker2());
runShift(new HumanWorker());

SOLID: five pressures toward the same shape

Each principle attacks a different symptom, but all five push toward small, focused units wired together through stable abstractions.

S: Order  -->  Order + ReceiptFormatter        (split by reason to change)
  O: if/elseif chain --> ShippingStrategyInterface (extend via new classes)
  L: Square extends Rectangle --> both implement HasAreaInterface (honest contract)
  I: fat WorkerInterface --> Workable + Eatable + Sleepable (role-sized interfaces)
  D: new MySqlConnection() inline --> DatabaseInterface injected (abstraction, not detail)

Important Rules

Single Responsibility: give a class one reason to change; split classes whose responsibilities change for unrelated reasons.

Open/Closed: prefer adding a new class/implementation over editing an existing, already-tested conditional chain — use polymorphism instead of type-checking branches.

Liskov Substitution: a subclass must honor its parent's full contract (accepted inputs, returned outputs, thrown exceptions) — if it can't, the relationship shouldn't be inheritance at all.

Interface Segregation: keep interfaces small and role-specific so no implementer is forced to fake support for a method it can't honestly provide.

Dependency Inversion: business logic should depend on interfaces/abstractions, never directly instantiate or hardcode a specific low-level implementation.

Where this shows up

PSR interfaces in the PHP ecosystem are SOLID applied at the language-ecosystem level: Psr\Log\LoggerInterface, Psr\Http\Message\ResponseInterface, and Psr\Container\ContainerInterface are small, role-specific contracts (Interface Segregation) that let frameworks and libraries depend on abstractions instead of concrete implementations (Dependency Inversion), so a Monolog logger and a custom logger are interchangeable anywhere LoggerInterface is expected. Laravel's and Symfony's event/listener systems and strategy-pattern-heavy areas (validation rules, cache drivers, queue drivers) are direct applications of Open/Closed — new drivers are added as new classes implementing a stable interface, never by editing a central dispatch function.

class Square extends Rectangle { public function setWidth(float $w): void { $this->width = $this->height = $w; } } interface HasAreaInterface { public function area(): float; } final class Square implements HasAreaInterface { /* independent, honest contract */ }

Forcing Square to extend Rectangle and override its setters to keep dimensions equal breaks Liskov Substitution: code written against Rectangle's contract gets silently wrong behavior when handed a Square. Model the two as separate implementers of a narrower shared interface instead.

interface WorkerInterface { public function work(): void; public function eat(): void; } class RobotWorker implements WorkerInterface { public function eat(): void { throw new LogicException('n/a'); } } interface Workable { public function work(): void; } interface Eatable { public function eat(): void; } class RobotWorker implements Workable { /* only implements what it truthfully supports */ }

A throwing stub method is a class lying about its own type — implementing WorkerInterface claims RobotWorker can eat, and it can't. Segregating the interface so RobotWorker implements only Workable removes the lie entirely.

[multiple-choice] Which SOLID principle does replacing a long if/elseif type-check chain with an interface and one implementing class per case satisfy? A. Single Responsibility B. Open/Closed Principle -- new behavior is added via new classes, without editing the existing dispatch code C. Liskov Substitution D. Interface Segregation

B. Open/Closed Principle -- new behavior is added via new classes, without editing the existing dispatch code — Open/Closed is specifically about extending behavior without modifying existing, already-tested code. Replacing a conditional chain with polymorphic implementations of a shared interface is the standard technique for achieving it.

[multiple-choice] Why does Square extends Rectangle (overriding setWidth/setHeight to keep both dimensions equal) violate the Liskov Substitution Principle? A. Because PHP does not allow overriding setter methods B. Because code written against Rectangle's contract (independently settable width and height) gets silently different, wrong behavior when it's actually handed a Square C. Because Square has fewer properties than Rectangle D. Because interfaces cannot be extended by classes

B. Because code written against Rectangle's contract (independently settable width and height) gets silently different, wrong behavior when it's actually handed a Square — Liskov Substitution requires a subtype to be usable anywhere the supertype is expected without surprising the caller. A caller that sets width and height independently on a Rectangle gets a broken result when the actual object is a Square, because Square's override secretly links the two dimensions together.

[multiple-choice] A RobotWorker class is forced to implement eat(): void from a shared WorkerInterface even though robots don't eat, so the method just throws an exception. Which principle addresses this directly? A. Interface Segregation -- interfaces should be small and role-specific, so implementers are never forced to fake support for a method they don't genuinely have B. Single Responsibility C. Dependency Inversion D. Open/Closed

A. Interface Segregation -- interfaces should be small and role-specific, so implementers are never forced to fake support for a method they don't genuinely have — Interface Segregation says no class should be forced to depend on methods it doesn't use. Splitting the fat WorkerInterface into smaller ones like Workable and Eatable lets RobotWorker implement only the capability it genuinely has.

[multiple-choice] A ReportService class does $this->db = new MySqlConnection(); directly inside its own constructor. Which SOLID principle does this most directly violate? A. Dependency Inversion -- high-level code should depend on an abstraction (a DatabaseInterface), not directly construct a specific low-level implementation B. Liskov Substitution C. Interface Segregation D. Single Responsibility, and only that one

A. Dependency Inversion -- high-level code should depend on an abstraction (a DatabaseInterface), not directly construct a specific low-level implementation — Dependency Inversion requires high-level modules to depend on abstractions rather than concrete low-level details. Hardcoding new MySqlConnection() welds ReportService to one specific database implementation, making it impossible to substitute another implementation (including a test double) without editing ReportService itself.

Take this Single-Responsibility violation: class User { public function __construct(public string $email) {} public function save(): void { /* writes to database */ } public function sendWelcomeEmail(): void { /* sends email */ } }. Refactor it into three classes so that persistence, notification, and the User data itself each live in their own class, connected through constructor-injected interfaces (UserRepositoryInterface, MailerInterface) rather than hardcoded implementations.

final class User { public function __construct(public readonly string $email) {} } interface UserRepositoryInterface { public function save(User $user): void; } interface MailerInterface { public function send(string $to, string $subject, string $body): void; } final class UserRegistrationService { public function __construct( private UserRepositoryInterface $repository, private MailerInterface $mailer, ) {} public function register(User $user): void { $this->repository->save($user); $this->mailer->send($user->email, 'Welcome!', 'Thanks for joining.'); } }

User should end up holding only data. A UserRepository implements UserRepositoryInterface handles save(); a WelcomeMailer implements MailerInterface handles sending the email; a higher-level UserRegistrationService coordinates both via constructor injection.