SOLID principles

Written in 2013 with Laravel 4 examples. Revised in 2026: the examples now use PHP 8.1+ and current Laravel, and a few inaccurate explanations in the original have been corrected.

SOLID is an acronym for five principles of object-oriented design. Robert C. Martin (Uncle Bob) gathered them together around 2000; Michael Feathers later coined the acronym:

Principle In one sentence
S Single Responsibility A class has only one reason to change
O Open/Closed Add features by writing new code, not by editing old code
L Liskov Substitution A subtype can stand in for its parent type and still behave as expected
I Interface Segregation Don’t force clients to depend on methods they don’t use
D Dependency Inversion Depend on abstractions, not on concrete implementations

All five share one goal: when requirements change, there should be few places to edit, and they should be close together.


S: Single Responsibility Principle

A class should have one, and only one, reason to change.

“Single responsibility” is often misread as “a class should do only one thing.” Uncle Bob later put it more precisely: a module should be responsible to one, and only one, actor — only one kind of person should ever ask for it to change.

This registration flow handles three things at once:

class RegisterController
{
    public function store(Request $request)
    {
        // 1. Validation: changes when the product spec changes
        $data = $request->validate([
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'min:8'],
        ]);

        // 2. Account creation: changes when the data model changes
        $user = User::create([
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
        ]);

        // 3. Welcome email: changes when marketing wants new copy or an SMS instead
        Mail::to($user)->send(new WelcomeMail($user));

        return redirect('/home');
    }
}

Three different people with three different reasons will all edit the same method. Split it up, and each class has a single reason to change:

// Validation rules
class RegisterUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'min:8'],
        ];
    }
}

// Create the account and fire a "registered" event
final class RegisterUser
{
    public function handle(string $email, string $password): User
    {
        $user = User::create([
            'email' => $email,
            'password' => Hash::make($password),
        ]);

        event(new Registered($user));

        return $user;
    }
}

// Send the welcome email on "registered"; switching to SMS only touches this class
final class SendWelcomeMail
{
    public function handle(Registered $event): void
    {
        Mail::to($event->user)->send(new WelcomeMail($event->user));
    }
}

// The controller only hands the HTTP request to the right collaborators
class RegisterController
{
    public function store(RegisterUserRequest $request, RegisterUser $register)
    {
        $register->handle($request->validated('email'), $request->validated('password'));

        return redirect('/home');
    }
}
flowchart LR
    subgraph before ["Before: three reasons to change one class"]
        direction TB
        P1(["Product"]) -->|"validation rules"| RC["RegisterController"]
        P2(["Engineering"]) -->|"data model"| RC
        P3(["Marketing"]) -->|"welcome email"| RC
    end
    subgraph after ["After: one actor per class"]
        direction TB
        Q1(["Product"]) --> R1["RegisterUserRequest"]
        Q2(["Engineering"]) --> R2["RegisterUser"]
        Q3(["Marketing"]) --> R3["SendWelcomeMail"]
    end
    before ~~~ after

Figure: before the split, requests from three actors all land on RegisterController; after it, each class answers to a single actor.

A rule of thumb: if you need the word “and” to describe what a class does, consider splitting it.


O: Open/Closed Principle

Software entities should be open for extension, but closed for modification.

Bertrand Meyer first stated this principle in 1988. It means: when requirements grow, extend the system by adding code, rather than by editing code that already works.

The classic symptom of a violation is an if or switch that keeps growing:

class FormValidator
{
    public function rules(string $form): array
    {
        // Every new form means coming back to edit this method
        return match ($form) {
            'login' => ['email' => ['required', 'email'], 'password' => ['required']],
            'register' => ['email' => ['required', 'email', 'unique:users'], 'password' => ['required', 'min:8']],
        };
    }
}

Instead, fix the shared flow in a parent class (closed for modification) and let each form supply its own rules through a subclass (open for extension):

abstract class FormValidation
{
    /** Each form defines its own rules */
    abstract protected function rules(): array;

    /** Custom error messages; forms that don't need them can skip this */
    protected function messages(): array
    {
        return [];
    }

    /** Throws ValidationException on failure; returns the validated data on success */
    final public function validate(array $input): array
    {
        return Validator::make($input, $this->rules(), $this->messages())->validate();
    }
}

final class LoginValidation extends FormValidation
{
    protected function rules(): array
    {
        return [
            'email' => ['required', 'email'],
            'password' => ['required', 'min:8'],
        ];
    }

    protected function messages(): array
    {
        return [
            'email.required' => 'Please enter your email.',
            'password.min' => 'The password must be at least 8 characters.',
        ];
    }
}

// A new registration form: add a class, without touching FormValidation or LoginValidation
final class RegisterValidation extends FormValidation
{
    protected function rules(): array
    {
        return [
            'email' => ['required', 'email', 'unique:users'],
            'password' => ['required', 'min:8', 'confirmed'],
        ];
    }
}
classDiagram
    class FormValidation {
        <<abstract>>
        #rules()* array
        #messages() array
        +validate(input) array
    }
    class LoginValidation {
        #rules() array
        #messages() array
    }
    class RegisterValidation {
        #rules() array
    }
    FormValidation <|-- LoginValidation
    FormValidation <|-- RegisterValidation
    note for FormValidation "validate() is final: the shared flow is closed for modification"
    note for RegisterValidation "A new form: just add this class, open for extension"

Figure: the parent class fixes the validation flow; subclasses only supply rules. A new form means a new subclass, with no changes to existing classes.

Marking validate() as final stops subclasses from rewriting the shared flow; they can only supply rules. Laravel’s FormRequest follows exactly this design.

Note that “closed” doesn’t simply mean making every property private. Encapsulation is a means; the real goal is that new requirements never force you to reopen old, tested code.


L: Liskov Substitution Principle

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

Barbara Liskov introduced this idea in 1987. The point is not merely that a subclass fits where its parent is expected — PHP’s type checks already guarantee that. The point is that a subclass must honour the contract its parent (or interface) promises, so callers never need to know which subclass they got.

A subtype must not:

  • Strengthen preconditions: it can’t reject input the parent accepts.
  • Weaken postconditions: it must deliver whatever the parent guarantees.
  • Throw exceptions the contract doesn’t mention.

Take this Disk interface, which promises that a file exists once save() returns:

final class File
{
    public function __construct(
        public readonly string $name,
        public readonly int $size,  // bytes
    ) {}
}

interface Disk
{
    /** Saves the file; afterwards exists($file->name) is true */
    public function save(File $file): void;

    public function exists(string $name): bool;
}

function backup(Disk $disk, File $file): void
{
    $disk->save($file);
    // By contract, we can safely assume the file now exists
}

Now add a FAT32-formatted thumb drive, which can’t hold a single file of 4GB or more:

final class Fat32Disk implements Disk
{
    private array $files = [];

    public function save(File $file): void
    {
        if ($file->size >= 4 * 1024 ** 3) {
            throw new LogicException('FAT32 cannot store a file of 4GB or more');
        }
        $this->files[$file->name] = $file;
    }

    public function exists(string $name): bool
    {
        return isset($this->files[$name]);
    }
}

The types are perfectly correct and backup() accepts it, yet it fails on any large file. Fat32Disk strengthens the precondition, so it breaks LSP. Callers end up writing if ($disk instanceof Fat32Disk), and the abstraction loses its meaning.

The fix is to write the limit into the contract, so every kind of disk expresses it the same way:

final class FileTooLarge extends RuntimeException {}

interface Disk
{
    /** Maximum size of a single file in bytes; null means no limit */
    public function maxFileSize(): ?int;

    /**
     * Saves the file; afterwards exists($file->name) is true.
     * Throws FileTooLarge when the file exceeds maxFileSize().
     */
    public function save(File $file): void;

    public function exists(string $name): bool;
}

“The file may be too large” is now part of the contract. backup() can check maxFileSize() up front or catch FileTooLarge, and it handles every kind of disk the same way.

The textbook counterexample is “a square inherits from a rectangle.” Mathematically a square is a rectangle, but a rectangle’s contract says width and height can be set independently, and a square can’t keep that promise. Inheritance should follow compatible behaviour, not a taxonomy of nouns.


I: Interface Segregation Principle

Clients should not be forced to depend upon interfaces that they do not use.

In PHP, implementing an interface means implementing every one of its methods. When an interface is too big, classes are forced to implement methods they can’t support, leaving them empty or throwing — which usually breaks LSP as well:

interface Animal
{
    public function walk(): void;
    public function fly(): void;
    public function swim(): void;
}

final class Penguin implements Animal
{
    public function walk(): void {}
    public function swim(): void {}

    public function fly(): void
    {
        throw new LogicException('Penguins can’t fly');  // forced to implement
    }
}

Split the interface by capability, so each class implements only what it can actually do:

interface Walkable
{
    public function walk(): void;
}

interface Flyable
{
    public function fly(): void;
}

interface Swimmable
{
    public function swim(): void;
}

final class Starck implements Walkable
{
    public function walk(): void {}
}

final class Pigeon implements Walkable, Flyable
{
    public function walk(): void {}
    public function fly(): void {}
}

final class Penguin implements Walkable, Swimmable
{
    public function walk(): void {}
    public function swim(): void {}
}

Callers, too, depend only on the capability they need. function race(Swimmable ...$swimmers) only cares whether you can swim: the penguin can enter, and the pigeon is turned away by the type check.

From a design point of view, interface segregation asks which behaviours are always used together, not “what can this thing do.”


D: Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

High-level modules hold business logic (“list the users”); low-level modules are implementation details (MySQL). Dependency inversion keeps the business logic untouched when details such as the data source, the payment provider or the mailer change.

Think of it as a sentence. The first version reads like “I’ll go to the MySQL database and fetch the user list”:

final class MySqlDatabase
{
    public function all(string $table): array
    {
        // SELECT * FROM ...
        return [];
    }
}

final class UserList
{
    public function users(): array
    {
        $db = new MySqlDatabase();  // decides on MySQL by itself
        return $db->all('users');
    }
}

UserList calls new on MySQL directly. Switching to another data source, or testing without a database, means editing it.

The second version passes the source in from outside — “I’ll go to the MySQL database and fetch the user list” — but MySQL is still spelled out in the sentence:

final class UserList
{
    public function __construct(private MySqlDatabase $db) {}

    public function users(): array
    {
        return $this->db->all('users');
    }
}

The third version uses a general term: “I’ll go to the user source and fetch the user list.” What that source actually is gets decided elsewhere:

interface UserRepository
{
    /** @return list<User> */
    public function all(): array;
}

final class EloquentUserRepository implements UserRepository
{
    public function all(): array
    {
        return User::all()->all();
    }
}

final class InMemoryUserRepository implements UserRepository
{
    /** @param list<User> $users */
    public function __construct(private array $users = []) {}

    public function all(): array
    {
        return $this->users;
    }
}

final class UserList
{
    public function __construct(private UserRepository $users) {}

    public function users(): array
    {
        return $this->users->all();
    }
}
flowchart LR
    subgraph before ["Before: business logic depends on details"]
        direction TB
        A["UserList"] --> B["MySqlDatabase"]
    end
    subgraph after ["After: details depend on an interface the business logic defines"]
        direction TB
        subgraph high ["High-level module"]
            C["UserList"] --> D[["UserRepository (interface)"]]
        end
        E["EloquentUserRepository"] -.->|"implements"| D
        F["InMemoryUserRepository"] -.->|"implements"| D
    end
    before ~~~ after

Figure: watch the arrows. Before, UserList points at MySQL; after, the database implementations point back at the interface on UserList’s side.

The “inversion” is that the high-level module defines the interface: UserRepository describes what UserList needs, not what MySQL happens to offer. Business logic used to depend on the database; now the database implementation conforms to an interface the business logic defines. The dependency points the other way.

It’s like an appliance that depends only on the shape of the wall socket. Whether the power comes from the grid or from solar panels on your roof, the appliance doesn’t need to know, and doesn’t need rewiring.

Swapping implementations in Laravel

Laravel’s service container injects dependencies based on constructor types. Bind the interface to an implementation in AppServiceProvider:

final class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UserRepository::class, EloquentUserRepository::class);
    }
}

In tests, swap in the in-memory version — no database needed:

$this->app->instance(UserRepository::class, new InMemoryUserRepository([
    new User(['name' => 'Starck']),
]));

Choosing a payment provider per user

The implementation can also be chosen at runtime, for example from the user’s preferred payment method:

interface PaymentGateway
{
    /** Charges the amount; returns the transaction ID */
    public function charge(int $amount): string;
}

final class PaypalGateway implements PaymentGateway
{
    public function charge(int $amount): string
    {
        // call the PayPal API
        return 'paypal-transaction-id';
    }
}

final class StripeGateway implements PaymentGateway
{
    public function charge(int $amount): string
    {
        // call the Stripe API
        return 'stripe-transaction-id';
    }
}

final class CheckoutController
{
    public function __construct(private PaymentGateway $gateway) {}

    public function store(Order $order)
    {
        $transactionId = $this->gateway->charge($order->total);
        // ...
    }
}

// AppServiceProvider::register()
$this->app->bind(PaymentGateway::class, fn ($app) => match (Auth::user()?->payment_method) {
    'paypal' => $app->make(PaypalGateway::class),
    default => $app->make(StripeGateway::class),
});

CheckoutController only knows there is “something that can take a payment.” Adding LINE Pay later means a new implementation and an updated binding; the checkout flow stays as it is. That also satisfies the Open/Closed Principle.


When you don’t need it

SOLID is a tool for managing change, not something every piece of code must follow. If there is only one implementation and no realistic chance of replacing it, a concrete class is usually simpler. Extract an interface when a second implementation actually shows up, or when you need to swap it out for tests.

Principle Signs you are breaking it
Single Responsibility Unrelated requirements all edit the same class; describing it takes an “and”
Open/Closed Every new type means reopening the same if/switch
Liskov Substitution Callers check the concrete class with instanceof; a subclass throws LogicException('not supported')
Interface Segregation Implementations contain empty methods or throw “not supported”
Dependency Inversion Business logic calls new on databases or API clients; tests can’t run without external services