← Back to all articles

SOLID Principles & Design Patterns: How to Write Clean, Maintainable PHP Code

Published on August 22, 2026  |  20 views
#PHP #OOP #SOLID #SOLID Principles #Design Patterns #Clean Code #Software Architecture #Backend Development #Dependency Injection #Factory Pattern #Strategy Pattern #Repository Pattern #Observer Pattern

 

                                 

SOLID Principles & Design Patterns: How to Write Clean, Maintainable PHP Code

Writing code that works is easy.

Writing code that remains easy to understand, extend and maintain after the application becomes large is much harder.

This is where SOLID principles and Design Patterns become useful.

In this article, I'll explain the five SOLID principles using practical PHP examples and then show how common design patterns such as Factory, Strategy, Repository and Dependency Injection can help us build better applications.


What Are SOLID Principles?

SOLID is an acronym for five object-oriented programming principles:

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

The goal isn't to blindly apply all five principles everywhere.

The goal is to write code that is:

  • Easier to understand

  • Easier to test

  • Easier to change

  • Less tightly coupled

  • Easier to extend

  • More maintainable


1. Single Responsibility Principle

"A class should have one reason to change."

This is probably the easiest SOLID principle to understand.

Consider this class:

class UserService
{
    public function createUser(array $data)
    {
        // Create user
    }

    public function sendEmail($user)
    {
        // Send email
    }

    public function generateReport($user)
    {
        // Generate PDF report
    }

    public function saveLog($user)
    {
        // Save log
    }
}

This class is doing too many things.

It handles:

User creation
     +
Email
     +
PDF generation
     +
Logging

If the email system changes, this class changes.

If the PDF library changes, this class changes.

If logging changes, this class changes.

That means it has multiple reasons to change.


Better Approach

Separate responsibilities:

class UserService
{
    public function createUser(array $data)
    {
        // Create user
    }
}

Then:

class EmailService
{
    public function send($user)
    {
        // Send email
    }
}

And:

class ReportService
{
    public function generate($user)
    {
        // Generate report
    }
}

Now each class has a clear responsibility.

UserService
     ↓
User related operations

EmailService
     ↓
Email related operations

ReportService
     ↓
Report generation

This makes the application easier to test and maintain.


2. Open/Closed Principle

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

Imagine you have a payment service:

class PaymentService
{
    public function pay($type, $amount)
    {
        if ($type === 'card') {
            // Card payment
        }

        if ($type === 'upi') {
            // UPI payment
        }

        if ($type === 'paypal') {
            // PayPal payment
        }
    }
}

Initially this works.

But imagine adding:

Credit Card
UPI
PayPal
Stripe
Razorpay
Apple Pay

Your PaymentService keeps getting modified.

This increases the risk of breaking existing functionality.


Better Approach

Define an interface:

interface PaymentGateway
{
    public function pay(float $amount): bool;
}

Then create implementations:

class UpiPayment implements PaymentGateway
{
    public function pay(float $amount): bool
    {
        // UPI payment
        return true;
    }
}
class CardPayment implements PaymentGateway
{
    public function pay(float $amount): bool
    {
        // Card payment
        return true;
    }
}

Now we can add another payment method without modifying the existing implementations.

PaymentGateway
      |
      ├── UpiPayment
      ├── CardPayment
      ├── PaypalPayment
      └── StripePayment

The system is open for extension but requires less modification to existing code.


3. Liskov Substitution Principle

This principle is often the most confusing one.

The basic idea is:

A child class should be usable wherever its parent class is expected without breaking the application.

Consider:

class Bird
{
    public function fly()
    {
        return "Flying";
    }
}

Now:

class Sparrow extends Bird
{
}

This is fine because a sparrow can fly.

But:

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

Now we have a design problem.

If the application expects every Bird to fly:

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

Passing a penguin breaks the expected behavior.


Better Design

Separate the concepts:

interface Bird
{
    public function eat();
}

Then:

interface FlyingBird
{
    public function fly();
}

Now:

class Sparrow implements Bird, FlyingBird
{
    public function eat()
    {
    }

    public function fly()
    {
    }
}

And:

class Penguin implements Bird
{
    public function eat()
    {
    }
}

The design now represents reality better.


4. Interface Segregation Principle

"Clients should not be forced to depend on interfaces they do not use."

Imagine this interface:

interface Worker
{
    public function work();

    public function eat();

    public function sleep();
}

A robot worker doesn't eat or sleep.

So implementing this interface creates unnecessary methods:

class Robot implements Worker
{
    public function work()
    {
    }

    public function eat()
    {
        // Robot doesn't eat
    }

    public function sleep()
    {
        // Robot doesn't sleep
    }
}

This is a bad abstraction.


Better Approach

Split the interfaces:

interface Workable
{
    public function work();
}
interface Eatable
{
    public function eat();
}
interface Sleepable
{
    public function sleep();
}

Now a robot only implements what it actually needs:

class Robot implements Workable
{
    public function work()
    {
        // Work
    }
}

While a human can implement multiple interfaces:

class Employee implements Workable, Eatable, Sleepable
{
    public function work()
    {
    }

    public function eat()
    {
    }

    public function sleep()
    {
    }
}

Small interfaces are usually easier to understand and maintain.


5. Dependency Inversion Principle

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

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

Consider:

class MySQLDatabase
{
    public function save($data)
    {
        // Save data
    }
}

And:

class UserService
{
    private MySQLDatabase $database;

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

Now UserService is tightly coupled to MySQL.

What happens if tomorrow you want:

MySQL
   ↓
MongoDB

You need to modify UserService.


Better Approach

Create an abstraction:

interface UserRepository
{
    public function save(array $data);
}

Then:

class MySQLUserRepository implements UserRepository
{
    public function save(array $data)
    {
        // Save to MySQL
    }
}

Now the service depends on the interface:

class UserService
{
    private UserRepository $repository;

    public function __construct(UserRepository $repository)
    {
        $this->repository = $repository;
    }
}

We can now provide another implementation:

class MongoUserRepository implements UserRepository
{
    public function save(array $data)
    {
        // Save to MongoDB
    }
}

The business logic doesn't need to know which database is being used.


Dependency Injection

This leads us to an important concept:

Dependency Injection (DI).

Instead of creating dependencies inside a class:

class OrderService
{
    public function __construct()
    {
        $this->mailer = new EmailMailer();
    }
}

we inject them:

class OrderService
{
    public function __construct(
        private MailerInterface $mailer
    ) {
    }
}

Now we can provide different implementations.

             MailerInterface
                    |
          ┌─────────┴─────────┐
          ↓                   ↓
    EmailMailer          SmsMailer

This makes the code easier to test and extend.


SOLID and Design Patterns Are Not the Same Thing

This is an important distinction.

SOLID principles are guidelines for designing better object-oriented software.

Design patterns are reusable solutions to common software design problems.

Think about it this way:

SOLID
  ↓
Design Principles
  ↓
Better Object Design
  ↓
Design Patterns
  ↓
Reusable Solutions

They complement each other.


Common Design Patterns I Use

There are many design patterns, but you don't need to memorize all of them.

Here are some particularly useful patterns for backend applications.


1. Factory Pattern

Factory is useful when object creation depends on some condition.

Instead of:

if ($type === 'email') {
    $sender = new EmailSender();
} elseif ($type === 'sms') {
    $sender = new SmsSender();
}

we can centralize object creation:

class NotificationFactory
{
    public static function create(string $type)
    {
        return match ($type) {
            'email' => new EmailSender(),
            'sms'   => new SmsSender(),
        };
    }
}

Usage:

$sender = NotificationFactory::create('email');

Now the creation logic has a dedicated place.


2. Strategy Pattern

Strategy is useful when you have multiple algorithms or behaviors.

For example:

Payment
   |
   ├── Credit Card
   ├── UPI
   ├── PayPal
   └── Bank Transfer

Instead of creating one huge if/else block, each strategy implements the same interface.

interface PaymentStrategy
{
    public function pay(float $amount): bool;
}

Then:

class UpiStrategy implements PaymentStrategy
{
    public function pay(float $amount): bool
    {
        // UPI logic
        return true;
    }
}

The application can switch strategies without changing the main business logic.


3. Repository Pattern

Repository separates data access from business logic.

Without a repository:

class UserService
{
    public function getUsers()
    {
        return User::where('status', 'active')->get();
    }
}

Now the service knows about database implementation.

With a repository:

interface UserRepository
{
    public function getActiveUsers();
}

Implementation:

class MySQLUserRepository implements UserRepository
{
    public function getActiveUsers()
    {
        return User::where('status', 'active')->get();
    }
}

Service:

class UserService
{
    public function __construct(
        private UserRepository $repository
    ) {
    }

    public function getUsers()
    {
        return $this->repository->getActiveUsers();
    }
}

The business layer doesn't need to know how the data is stored.


4. Observer Pattern

Observer is useful when one event should trigger multiple actions.

For example:

User Registered
      |
      ├── Send Email
      ├── Send Notification
      ├── Create Audit Log
      └── Update Analytics

Instead of putting everything inside:

registerUser()

we can publish an event:

event(new UserRegistered($user));

Different listeners can react to it.

This makes the system more loosely coupled.


SOLID + Design Patterns Together

The real power comes when these concepts are combined.

For example:

                    OrderService
                         |
                         ↓
                 PaymentInterface
                         |
              ┌──────────┼──────────┐
              ↓          ↓          ↓
            UPI        Card       PayPal
          Strategy     Strategy    Strategy
              |
              ↓
           Factory

This architecture gives us:

  • Loose coupling

  • Easier testing

  • Easier extension

  • Cleaner business logic

  • Better separation of concerns


Don't Overuse Design Patterns

There is another side to this discussion.

You don't need a design pattern for every class.

This:

class User
{
    public function getName()
    {
        return $this->name;
    }
}

doesn't need five interfaces, three factories and an abstract strategy.

Overengineering can be worse than simple code.

A good rule is:

Use patterns when they solve a real problem, not because they look impressive.


My Practical Rules

When designing a backend application, I usually think about these questions:

1. Does this class have too many responsibilities?

If yes → consider Single Responsibility.

2. Am I constantly modifying existing code to add new behavior?

If yes → consider Open/Closed and possibly Strategy.

3. Does a child class break the expectations of its parent?

If yes → check Liskov Substitution.

4. Is an interface becoming too large?

If yes → consider Interface Segregation.

5. Is my business logic tightly coupled to MySQL, Redis, an API or a framework?

If yes → consider Dependency Inversion.

6. Am I creating many objects based on conditions?

Consider Factory.

7. Do I have multiple interchangeable algorithms?

Consider Strategy.

8. Is my business logic mixed with database queries?

Consider Repository.

9. Should multiple components react to an event?

Consider Observer.


Final Thoughts

SOLID principles aren't about making code complicated.

They are about making code easier to change.

A good application should allow you to change:

MySQL → PostgreSQL
Email → SMS
UPI → Stripe
Redis → Another Cache
API → Another API

without rewriting your entire business logic.

That's the real value of good software design.

My approach is simple:

Keep classes focused
        ↓
Depend on abstractions
        ↓
Reduce coupling
        ↓
Prefer composition
        ↓
Use patterns when useful
        ↓
Keep the code simple

SOLID principles give you the foundation.

Design patterns give you reusable tools.

But good engineering comes from knowing when not to use them as well.


What's Next?

In the next article, I'll take a real PHP application example and refactor a large "God Class" step by step using SOLID principles.

We'll start with badly structured code and gradually transform it into a cleaner, testable design.


Author: Santosh Chakraborty

Topics: PHP, OOP, SOLID, Design Patterns, Software Architecture, Clean Code, Backend Engineering