Write Better Code with SOLID Principles (PHP Examples)

solid principles
(Image credit: Canva)

SOLID principles are a set of five fundamental guidelines in object-orientated programming that help developers create maintainable, flexible, and understandable code. By adhering to these principles, code becomes more maintainable, readable, and easier to extend, modify, and debug, leading to more efficient development and higher-quality software. The benefits include:

  • Improved Code Quality: SOLID principles promote well-structured and organised code.
  • Readability: Code that follows SOLID principles is easier to understand.
  • Code Reusability: By designing code to be more modular and less tightly coupled, SOLID principles facilitate the reuse of components in different parts of your application
  • Maintainability: Code that adheres to SOLID principles is easier to maintain and modify, which in turn reduces the chances of introducing bugs when making changes.
  • Scalability: When SOLID principles have been adopted, it becomes easier to scale and introduce new features.
  • Design and Architecture: SOLID principles encourage a more thoughtful approach to designing and architecting software which leads to more effective and efficient solutions.
  • Reduced Risk: Helps mitigate the risk of critical errors and vulnerabilities

Okay, so now that we've talked about the benefits of SOLID principles and why developers should strive to adopt them... What actually are they? Let's take a look at each one, with some examples, written in PHP.

1. The Single Responsibility Principle

The first principle of SOLID is the Single Responsibility Principle (SRP). This principle states that a class should have a single, well-defined responsibility or function within the system. SRP encourages the separation of concerns in our software, leading to clean and maintainable code that is easier to understand, modify, and test. 

Let's take a look at an example:

<?php

class User {
    public function __construct(
        private string $firstName,
        private string $lastName,
        private string $email,
    ) {
    }

    public function save() {
        // Store attributes into a database...
    }
}

In the above example, the User class violates SRP as there are two responsibilities that are handled here. The first responsibility is to model the user data and the second is storing the user in the database. The solution here would be to move the database logic into its own class.

<?php

class User {
    public function __construct(
        private string $firstName,
        private string $lastName,
        private string $email,
    ) {
    }
}

class UserDb {
    public function save(User $user) {
        // Store attributes into a database...
    }
}

2. The Open-Closed Principle

The Open-Closed Principle states that software entities (classes, functions, modules) should be open for extension but closed for modification. What this effectively means is that once an entity, for example, a class, is defined and working correctly, it should not be modified to add additional functionality. Instead, we should look at extending its behavior, while not changing its source code. 

This principle encourages the use of inheritance, interfaces, and design patterns and promotes code reusability, while also minimising the risk of bugs when making changes to existing code.

Let's take a look at an example:

<?php

class Shape {
    public function draw(string $type) {
       if ($type === 'circle') {
           //Draw a circle
       } elseif ($type === 'square') {
           //Draw a square
       }
    }
}

This example violates the Open-Closed Principle as if you want to add a new type of shape then you would need to modify the Shape class. Let's take a look at how this can be refactored to adhere to the principle.

<?php

interface IDrawable {
    public function draw(string $type);
}


class Circle implements IDrawable {
    public function draw(string $type)
    {
        // Draw a circle
    }
}


class Square implements IDrawable {
    public function draw(string $type)
    {
        // Draw a square
    }
}

The above example now follows the Open-Closed Principle as it is closed for modification due to the main functionality being in place, however, it is open for extension as we can draw any Shape as long as it implements the Shape interface.

3. Liskov Substitution Principle

The Liskov Substitution Principle (LSP) states that derived classes should be substitutable for their base classes without altering the correctness of the program. Put simply, if a class is a subtype of another class, it should seamlessly replace the parent class without causing issues.

Let's take a look at an example:

<?php

class Rectangle
{
    public function __construct(
        private float $width,
        private float $height
    ) {
    }

    public function getWidth(): float
    {
        return $this->width;
    }

    public function setWidth(float $width): void
    {
        $this->width = $width;
    }

    public function getHeight(): float
    {
        return $this->height;
    }

    public function setHeight(float $height): void
    {
        $this->height = $height;
    }

    public function getArea(): float
    {
        return $this->width * $this->height;
    }
}


class Square extends Rectangle
{
    public function __construct(float $sideLength) {
        parent::__construct($sideLength, $sideLength);
    }

    public function setWidth(float $width): void
    {
        $this->width = $width;
        $this->height = $width;
    }

    public function setHeight(float $height): void
    {
        $this->height = $height;
        $this->width = $height;
    }
}

In the example above, mathematically speaking, we could say that a Square could extend a Rectangle. This would mean that we could indeed pass around a Square, wherever a Rectangle is expected within our application, however by doing so, we will break the assumptions that are made about the behavior of a Rectangle. Let's take a look at a simple test as an example:

public function testRectangleArea(Rectangle $rectangle): bool
{
    $rectangle->setWidth(5);
    $rectangle->setHeight(4);
    
    return $rectangle->getArea() === 20.0;
}

Looking at this test, it looks like it should pass, however, if we were to pass through a Square to the test, it would fail, as we have broken the assumptions made for a Rectangle when overriding the setWidth() and setHeight() functions for the Square.

Now let's take a look at how we can refactor this to respect the Liskov Substitution Principle:

<?php

interface IShape {
    public function getArea(): float;
}


class Rectangle implements IShape
{
    public function __construct(
        private float $width,
        private float $height
    ) {
    }
    
    //getter and setters...
    
    public function getArea(): float
    {
        return $this->width * $this->height;
    }
}


class Square implements IShape
{
    public function __construct(
        private float $sideLength
    ) {
    }
    
    //getter and setters...
    
    public function getArea(): float
    {
        return $this->sideLength * $this->sideLength;
    }
}

By using the IShape interface here, we have separated the Rectangle and Square classes, ensuring that they can define and set their own properties accordingly while being able to define their own implementation of getArea(), therefore solving the previous issue of unexpected behavior from subclasses.

4. Interface Segregation Principle

The Interface Segregation Principle states that "a client should never be forced to implement an interface that it doesn’t use, or clients shouldn’t be forced to depend on methods they do not use."

Lets take a look at an example:

<?php

interface IAnimal {
    public function communicate(): void;
    public function numberOfLegs(): int;
}

class Dog implements IAnimal {
    public function communicate(): void
    {
        echo "bark";
    }

    public function numberOfLegs(): int
    {
        return 4;
    }
}

class Fish implements IAnimal {
    public function communicate(): void
    {
        //A fish can't communicate
        echo "";
    }

    public function numberOfLegs(): int
    {
        return 0;
    }
}

In the above example, both animals implement the IAnimal interface, which contains the communicate() and numberOfLegs() functions. Although both of these functions are valid for most animals, for some they can be redundant. In this example, a fish cannot communicate, so it is a fairly redundant function that needs to be implemented. To handle this problem we can split the interface up like so:

<?php

interface IAnimal {
    public function numberOfLegs(): int;
}

interface ICommunicate {
    public function communicate(): void;
}

class Dog implements IAnimal, ICommunicate {
    public function communicate(): void
    {
        echo "bark";
    }

    public function numberOfLegs(): int
    {
        return 4;
    }
}

class Fish implements IAnimal {
    public function numberOfLegs(): int
    {
        return 0;
    }
}

5. The Dependency Inversion Principle

The Dependency Inversion Principle refers to the decoupling of software modules. The principle states that the classes within our application should depend upon interfaces or abstract classes instead of concrete classes and functions.

By following the Dependency Inversion Principle, we can create a more flexible and maintainable codebase. It allows for easier substitution of implementations and promotes a decoupled architecture, which makes it easier to modify and extend our applications.

Let's take a look at an example:

<?php

//Low-level module
class EmailSender {
    public function sendEmail(string $to, string $subject, string $body): void
    {
        //Implementation to send email
    }
}

//High-level module
class NotificationService {
    //The constructor violates the Dependency Inversion Principle as it depends on a specific low-level module
    public function __construct(
        private EmailSender $emailSender
    ) {
    }
    
    public function sendNotification(User $user, string $subject, string $message): void
    {
        $this->emailSender->sendNotification($user->getEmail(), $subject, $message);
    }
}

The above example violates the Dependency Inversion Principle as the constructor is dependent on injecting a low-level module, the EmailSender. This therefore makes our application harder to extend, if, for example, we wanted to introduce another low-level notification service.

Let's take a look at how we can fix this to adhere to the principle:

<?php

//Interface
interface INotificationSender {
    public function sendNotification(User $user, string $subject, string $message): void;
}

//Low-level module
class EmailSender implements INotificationSender {
    public function sendNotification(User $user, string $subject, string $message): void
    {
        //Implementation to send email
    }
}

//High-level module
class NotificationService {
    public function __construct(
        private INotificationSender $notificationSender
    ) {
    }

    public function sendNotification(User $user, string $subject, string $message): void
    {
        $this->notificationSender->sendNotification($user, $subject, $message);
    }
}

In the above example we have fixed the violation by introducing an abstraction for the low-level modules, by implementing an interface. The NotificationService now depends on an instance of the interface rather than a concrete class.

Software Developer