Articles/PHP/SOLID Principles in Modern PHP

SOLID Principles in Modern PHP

SOLID has not changed in years, but PHP has. Here are the five object-oriented design principles rewritten for PHP 8.5, using typed properties, readonly, enums, constructor promotion, and property hooks to express the same ideas with far less boilerplate.

June 14, 2026·9 min read

SOLID is a set of five object-oriented design principles, popularized by Robert C. Martin, that help you write code that is easier to extend, test, and maintain. The ideas have not changed in years. PHP, on the other hand, has changed enormously. When I first wrote about SOLID in 2016, expressing these principles took a lot of ceremony: manual property assignment, no real enums, getters and setters everywhere. PHP 8.5 strips most of that away, so the principles shine through with far less code. Let's walk through all five with modern PHP.

Single Responsibility Principle

A class should have one reason to change, which really just means it should do one job. The classic mistake is a class that both holds data and knows how to deliver it. Here the Notification holds the message, and nothing else.

PHP
final class Notification
{
    public function __construct(
        public readonly string $subject,
        public readonly string $body,
    ) {}
}

Because it is a readonly value object, once constructed it cannot be mutated, so there is exactly one reason it would ever change: the shape of a notification. Delivery is a separate concern, so it lives behind its own interface.

PHP
interface Channel
{
    public function send(Notification $notification, string $to): void;
}

final class EmailChannel implements Channel
{
    public function __construct(private readonly Mailer $mailer) {}

    public function send(Notification $notification, string $to): void
    {
        $this->mailer->deliver($to, $notification->subject, $notification->body);
    }
}

Now if the way we model a message changes, we touch Notification. If the way we talk to an email provider changes, we touch EmailChannel. The two never bleed into each other.

Open-Closed Principle

Code should be open for extension but closed for modification. In practice that means you should be able to add behavior without editing the classes that are already written, working, and tested. Channels make this easy: adding Slack support is a new class, not a change to an old one.

PHP
final class SlackChannel implements Channel
{
    public function __construct(private readonly SlackClient $slack) {}

    public function send(Notification $notification, string $to): void
    {
        $this->slack->post($to, "*{$notification->subject}*\n{$notification->body}");
    }
}

Nothing that already exists has to change. The same idea applies inside a class when you would otherwise reach for a sprawling conditional. An enum paired with match keeps the branches in one well-typed place, and adding a case is an additive change the compiler helps you complete.

PHP
enum Priority: string
{
    case Low = 'low';
    case Normal = 'normal';
    case Urgent = 'urgent';

    public function maxRetries(): int
    {
        return match ($this) {
            self::Low, self::Normal => 1,
            self::Urgent => 5,
        };
    }
}

If you add a Priority::Critical case later, match will throw on the unhandled value instead of silently falling through, so the gap is impossible to miss.

Liskov Substitution Principle

Anywhere you use a type, you should be able to swap in any of its subtypes without surprises. This is about behavior, not just matching signatures. Every Channel must genuinely honor the contract of send(): deliver the message, or fail loudly, but never silently pretend. A dispatcher can then treat them all the same.

PHP
final class Dispatcher
{
    /** @param list<Channel> $channels */
    public function __construct(private readonly array $channels) {}

    public function dispatch(Notification $notification, string $to): void
    {
        foreach ($this->channels as $channel) {
            $channel->send($notification, $to);
        }
    }
}

Because each channel upholds the same contract, the dispatcher does not need to know or care which ones it was handed. A channel that swallowed errors and returned as though it had delivered would technically satisfy the interface, but it would violate Liskov, and any caller relying on delivery would quietly break.

Interface Segregation Principle

No class should be forced to depend on methods it does not use. The fix is small, focused interfaces instead of one large one. A paper book and an e-reader can both turn pages, but only the e-reader can bookmark, so those are two different capabilities.

PHP
interface Pageable
{
    public function nextPage(): void;
    public function previousPage(): void;
}

interface Bookmarkable
{
    public function bookmark(): void;
}

final class PaperBook implements Pageable
{
    public function nextPage(): void { /* turn the physical page */ }
    public function previousPage(): void { /* turn it back */ }
}

final class Ebook implements Pageable, Bookmarkable
{
    public function nextPage(): void { /* render the next screen */ }
    public function previousPage(): void { /* render the previous screen */ }
    public function bookmark(): void { /* persist the reader's position */ }
}

PaperBook implements only what it can actually do, with no empty bookmark() method left lying around. Since PHP 8.4, interfaces can even declare properties, including the hooks used to read them, so an interface can require a small slice of state without dragging in behavior a client does not need.

PHP
interface HasTitle
{
    public string $title { get; }
}

final class Post implements HasTitle
{
    public function __construct(private readonly string $headline) {}

    public string $title {
        get => trim($this->headline);
    }
}

A collaborator that only needs a title can depend on HasTitle and nothing more.

Dependency Inversion Principle

High-level code should depend on abstractions, not on concrete implementations. Notice that Dispatcher already does this: it accepts Channel instances, never a specific EmailChannel. You wire the concrete pieces together at the edge of your application and inject them in.

PHP
$dispatcher = new Dispatcher([
    new EmailChannel($mailer),
    new SlackChannel($slack),
]);

$dispatcher->dispatch(
    new Notification('Welcome aboard', 'Thanks for signing up.'),
    'frank@fjp.io',
);

Constructor property promotion makes the injection a single line, and marking the dependency readonly guarantees nothing swaps it out after construction. When you want a property the outside world can read but only the class can change, asymmetric visibility says exactly that.

PHP
final class ChannelRegistry
{
    /** @var list<Channel> */
    public private(set) array $channels = [];

    public function register(Channel $channel): void
    {
        $this->channels[] = $channel;
    }
}

The registry exposes its channels for inspection while keeping the only path to mutate them inside the class. High-level code depends on the Channel interface throughout, so you can swap email for Slack, or drop in a fake channel during a test, without touching the parts of the system that actually matter.

Wrapping Up

None of these principles are new, and none are specific to PHP. What modern PHP changes is how little code it now takes to follow them. Typed and readonly properties make intent explicit, promotion and enums cut the boilerplate, and property hooks with asymmetric visibility let small interfaces describe exactly what a collaborator needs. Even newer conveniences, like the pipe operator added in PHP 8.5, reward the same habit of building small, single-purpose units that compose cleanly.

Treat SOLID as a set of guidelines, not laws. You will not always follow every one to the letter, and that is fine. But knowing them gives you a vocabulary for why one design feels easier to change than another. If you are curious how all of this looked a decade ago, the 2016 version of this article works through the same ideas in the PHP of its day, and the contrast is its own little lesson.