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:
UserRepositorysaves user dataEmailServicesends emailLoggerwrites 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.