Milind Daraniya

SOLID Principles in PHP: Simple, Practical, and Easy to Understand with Real Code Examples

Published June 7th, 2026 23 min read

When I write PHP code, I do not only think about making it work. I also think about how easy it will be to maintain, extend, test, and understand after 6 months or 2 years.

That is exactly where SOLID principles help.

SOLID is not a framework feature. It is not a PHP trick. It is a set of five object-oriented design principles that help us write cleaner and more flexible code.

SOLID stands for:

  • S = Single Responsibility Principle
  • O = Open/Closed Principle
  • L = Liskov Substitution Principle
  • I = Interface Segregation Principle
  • D = Dependency Inversion Principle

In simple words, SOLID helps us avoid messy code, tight coupling, and classes that do too many things.

In this post, I will explain each principle in a very simple way, with real PHP examples that you can run directly in a .php file.


Why SOLID matters in PHP

Many PHP projects start small. At the beginning, one class can handle everything. It feels fast.

But later the same class becomes a headache:

  • one class has too many responsibilities
  • changing one feature breaks another
  • testing becomes difficult
  • code becomes hard to reuse
  • new developers cannot understand it quickly

SOLID solves these problems by encouraging better design.

If you are writing plain PHP, Laravel, or any OOP-based PHP application, these principles are still very useful.


1) Single Responsibility Principle (SRP)

Meaning

A class should have only one reason to change.

That means one class should do one job only.

Wrong idea

If a class handles user data, sends email, writes logs, and calculates tax, then that class is doing too much.

Simple rule

One class = one responsibility.


Example: Bad design

 
<?php

class UserManager
{
    public function saveUser($name, $email)
    {
        echo "Saving user: $name, $email\n";
    }

    public function sendWelcomeEmail($email)
    {
        echo "Sending welcome email to: $email\n";
    }

    public function logUserAction($message)
    {
        echo "Log: $message\n";
    }
}

$userManager = new UserManager();
$userManager->saveUser("Milind", "milind@example.com");
$userManager->sendWelcomeEmail("milind@example.com");
$userManager->logUserAction("User created");
 

Problem in this code

This class is doing three different jobs:

  • saving user
  • sending email
  • logging action

If email logic changes, this class changes.
If logging logic changes, this class changes again.
That means one class has many reasons to change.


Example: Good design

 
<?php

class UserRepository
{
    public function saveUser($name, $email)
    {
        echo "Saving user: $name, $email\n";
    }
}

class EmailService
{
    public function sendWelcomeEmail($email)
    {
        echo "Sending welcome email to: $email\n";
    }
}

class Logger
{
    public function log($message)
    {
        echo "Log: $message\n";
    }
}

$userRepository = new UserRepository();
$emailService = new EmailService();
$logger = new Logger();

$userRepository->saveUser("Milind", "milind@example.com");
$emailService->sendWelcomeEmail("milind@example.com");
$logger->log("User created");
 

Why this is better

Now each class has only one responsibility:

  • UserRepository saves user data
  • EmailService sends email
  • Logger writes logs

This makes the code easier to maintain and test.


2) Open/Closed Principle (OCP)

Meaning

A class should be open for extension but closed for modification.

This means:

  • you should be able to add new behavior
  • without changing existing tested code too much

In simple words, do not keep editing old code every time you add a new feature.


Example: Bad design

 
<?php

class DiscountCalculator
{
    public function calculateDiscount($type, $price)
    {
        if ($type === 'regular') {
            return $price * 0.05;
        }

        if ($type === 'premium') {
            return $price * 0.10;
        }

        return 0;
    }
}

$calculator = new DiscountCalculator();

echo $calculator->calculateDiscount('regular', 1000) . "\n";
echo $calculator->calculateDiscount('premium', 1000) . "\n";
 

Problem

Every time a new discount type comes, we must modify the calculateDiscount() method.

For example:

  • gold user
  • employee user
  • festival offer
  • coupon offer

This will keep growing. The class becomes harder to manage.


Example: Good design using interfaces

 
<?php

interface DiscountInterface
{
    public function calculate($price);
}

class RegularDiscount implements DiscountInterface
{
    public function calculate($price)
    {
        return $price * 0.05;
    }
}

class PremiumDiscount implements DiscountInterface
{
    public function calculate($price)
    {
        return $price * 0.10;
    }
}

class DiscountCalculator
{
    public function getDiscount(DiscountInterface $discount, $price)
    {
        return $discount->calculate($price);
    }
}

$calculator = new DiscountCalculator();

echo $calculator->getDiscount(new RegularDiscount(), 1000) . "\n";
echo $calculator->getDiscount(new PremiumDiscount(), 1000) . "\n";
 

Why this is better

Now if I want to add a new discount type, I do not modify DiscountCalculator.

I just create a new class:

 
class FestivalDiscount implements DiscountInterface
{
    public function calculate($price)
    {
        return $price * 0.20;
    }
}
 

That is Open/Closed Principle in action.


3) Liskov Substitution Principle (LSP)

Meaning

If class B is a child of class A, then B should be able to replace A without breaking the program.

In simple words, child class should behave properly like the parent class.

If inheritance is used in a wrong way, LSP breaks.


Example: Bad design

 
<?php

class Bird
{
    public function fly()
    {
        echo "Bird is flying\n";
    }
}

class Sparrow extends Bird
{
    public function fly()
    {
        echo "Sparrow is flying\n";
    }
}

class Penguin extends Bird
{
    public function fly()
    {
        throw new Exception("Penguins cannot fly");
    }
}

function makeBirdFly(Bird $bird)
{
    $bird->fly();
}

makeBirdFly(new Sparrow());

// This will break because Penguin cannot fly
makeBirdFly(new Penguin());
 

Problem

Penguin is a bird, but it cannot fly.
So making Penguin a child of Bird with a fly() method is a bad design.

The program breaks when we try to use Penguin where Bird is expected.


Example: Good design

 
<?php

interface Flyable
{
    public function fly();
}

class Sparrow implements Flyable
{
    public function fly()
    {
        echo "Sparrow is flying\n";
    }
}

class Eagle implements Flyable
{
    public function fly()
    {
        echo "Eagle is flying\n";
    }
}

class Penguin
{
    public function swim()
    {
        echo "Penguin is swimming\n";
    }
}

function makeItFly(Flyable $bird)
{
    $bird->fly();
}

makeItFly(new Sparrow());
makeItFly(new Eagle());

$penguin = new Penguin();
$penguin->swim();
 

Why this is better

Now only flying animals implement Flyable.

Penguin does not pretend to fly.
So the design is correct and safe.


4) Interface Segregation Principle (ISP)

Meaning

A class should not be forced to implement methods it does not need.

In simple words, do not create a big interface with many unrelated methods.

Instead, make small and focused interfaces.


Example: Bad design

 
<?php

interface Worker
{
    public function work();
    public function eat();
}

class HumanWorker implements Worker
{
    public function work()
    {
        echo "Human is working\n";
    }

    public function eat()
    {
        echo "Human is eating\n";
    }
}

class RobotWorker implements Worker
{
    public function work()
    {
        echo "Robot is working\n";
    }

    public function eat()
    {
        // Robot does not eat, but still must implement this method
        echo "Robot does not eat\n";
    }
}

$human = new HumanWorker();
$robot = new RobotWorker();

$human->work();
$human->eat();

$robot->work();
$robot->eat();
 

Problem

RobotWorker should not be forced to have eat().

That method is useless for a robot. This is a sign that the interface is too broad.


Example: Good design

 
<?php

interface Workable
{
    public function work();
}

interface Eatable
{
    public function eat();
}

class HumanWorker implements Workable, Eatable
{
    public function work()
    {
        echo "Human is working\n";
    }

    public function eat()
    {
        echo "Human is eating\n";
    }
}

class RobotWorker implements Workable
{
    public function work()
    {
        echo "Robot is working\n";
    }
}

$human = new HumanWorker();
$robot = new RobotWorker();

$human->work();
$human->eat();

$robot->work();
 

Why this is better

Now each class implements only what it actually needs.

This makes interfaces cleaner and classes simpler.


5) Dependency Inversion Principle (DIP)

Meaning

High-level code should not depend on low-level code directly.
Both should depend on abstractions.

In simple words:

  • depend on interfaces
  • not on concrete classes

This makes code flexible and easy to replace.


Example: Bad design

 
<?php

class MySQLDatabase
{
    public function save()
    {
        echo "Saving data in MySQL\n";
    }
}

class UserService
{
    private $database;

    public function __construct()
    {
        // Direct dependency on a concrete class
        $this->database = new MySQLDatabase();
    }

    public function createUser()
    {
        echo "Creating user...\n";
        $this->database->save();
    }
}

$userService = new UserService();
$userService->createUser();
 

Problem

UserService depends directly on MySQLDatabase.

If tomorrow I want to use:

  • PostgreSQL
  • MongoDB
  • file storage
  • fake database for testing

then I must change UserService.

That is tight coupling.


Example: Good design

 
<?php

interface DatabaseInterface
{
    public function save();
}

class MySQLDatabase implements DatabaseInterface
{
    public function save()
    {
        echo "Saving data in MySQL\n";
    }
}

class PostgreSQLDatabase implements DatabaseInterface
{
    public function save()
    {
        echo "Saving data in PostgreSQL\n";
    }
}

class UserService
{
    private $database;

    public function __construct(DatabaseInterface $database)
    {
        $this->database = $database;
    }

    public function createUser()
    {
        echo "Creating user...\n";
        $this->database->save();
    }
}

$userService1 = new UserService(new MySQLDatabase());
$userService1->createUser();

$userService2 = new UserService(new PostgreSQLDatabase());
$userService2->createUser();
 

Why this is better

Now UserService does not care which database is used.

It only knows about DatabaseInterface.

That means the class is:

  • easier to test
  • easier to extend
  • easier to maintain

This is one of the most important principles in real-world PHP applications.


SOLID in one simple real-world example

Let us imagine a user registration system.

A user registers, and we need to:

  • save user
  • send welcome email
  • log action
  • maybe give discount or reward later
  • support different storage systems

Without SOLID, many things get mixed into one class.

With SOLID, we separate responsibilities:

  • one class saves the user
  • one class sends email
  • one class logs activity
  • one interface defines database behavior
  • one class handles discount logic
  • one class supports each business rule

This keeps the project clean even when it grows.


How I think about SOLID in real PHP projects

When I design a class, I ask myself:

  • Is this class doing too much?
  • Will this class change for many different reasons?
  • Am I depending on concrete classes too early?
  • Can I replace this object without breaking the code?
  • Is this interface too big?
  • Can I split this logic into smaller pieces?

If the answer is yes, then the design probably needs improvement.


Common mistakes while learning SOLID

1. Overengineering

Do not create too many interfaces and classes just to follow SOLID blindly.

Start simple. Refactor when needed.

2. Mixing responsibilities

One class should not become a controller, service, logger, mailer, and repository together.

3. Wrong inheritance

Use inheritance only when the child is truly a type of parent.

If not, use composition or interfaces.

4. Huge interfaces

If a class does not need a method, that method does not belong in the interface.

5. Directly creating objects everywhere

Instead of hardcoding dependencies inside classes, pass them through constructor injection or abstraction.


A practical summary of all five principles

Single Responsibility Principle

One class should do one job.

Open/Closed Principle

Add new behavior without changing old code.

Liskov Substitution Principle

Child classes should work wherever parent classes are expected.

Interface Segregation Principle

Keep interfaces small and focused.

Dependency Inversion Principle

Depend on interfaces, not concrete classes.


Final thoughts

SOLID is not about writing more code.
It is about writing better code.

When I follow SOLID in PHP, I get:

  • cleaner classes
  • easier testing
  • less breakage
  • better reusability
  • better long-term maintenance

At the start, SOLID may look a little theoretical.
But once you use it in real projects, it becomes one of the most practical design tools in OOP.

If you are working in PHP seriously, especially in long-term projects or Laravel applications, SOLID is not optional in my opinion. It is one of the main reasons code stays healthy as the project grows.