DEV Community

Cover image for Solid PHP - SOLID principles in PHP
Michał Romańczuk for Accesto

Posted on • Originally published at accesto.com

Solid PHP - SOLID principles in PHP

SOLID, this acronym was coined by Michael Feathers, it represents the five basic principles of object-oriented programming developed by Uncle Bob.

Most programmers probably know this acronym. But it seems to me that a minority can decode it.

Is it wrong? In my opinion, no, I think that writing clean and simple code is more important than knowing the theory. But don't completely ignore the theory. How else will you pass on knowledge to someone? How do you justify your code in a discussion in code review? You have to be based on theory and generally accepted standards.

But it's also good to know the basics of what clean and simple code looks like.
SOLID principles can be used in any object-oriented programming language. I work in Symfony on a daily basis, so I will show some principles in PHP.

So let's go through the five SOLID principles together.

Single responsibility principle (SPR)

Single responsibility principle (SPR)

I think this is the most famous rule (probably because it is the first and some people did not read on). But seriously, I think it is very important.

Uncle Bob describes it as “A class should have one, and only one, a reason to change”. What does it mean? For me, this sentence is not helpful :).

Other explanations say that a class, a function should do one thing.

But what is one thing? Is user registration one thing? Or maybe it's more things, because registrations include some other smaller things, password encryption, saving to the database, sending an e-mail.

Is sending an email one thing? After all, it consists of many steps such as preparing the e-mail content and subject, extracting the user's e-mail address and handling the response. Should we set up a separate class for each of these activities? How far do we go with this “single responsibility”?!

I think we need to use a different object-oriented programming principle to answer this question. In 1974, the "high cohesion and low coupling" principle was described for the first time in the article Structured Design in the IBM journal.

I will try to present it in simple to understand examples.

Cohesion determines how much a function or class is responsible for. An simple example here would be Bob and Alice, the cook's helpers. Alice is making desserts. She needs to make a sponge cake, cream, glaze, cut the fruit and put it all together. Each of these steps consists of several others. It's an example of low cohesion. Bob's job is to peel potatoes, nothing else, it’s an example of high cohesion. Your method/class should be like Bob, do one thing.

Coupling is about how easy it is to reuse a given module or class. Puzzles and Lego blocks are a good example for that. The puzzles are characterized by high coupling. One puzzle fits only in one place, it cannot be combined with other puzzles. The opposite of Lego bricks, they have a low coupling, they can be combined freely and each one can be used anywhere. Your code should be like Lego blocks, easy to use in different places.

The single responsibility principle should be used together with the "high cohesion and low coupling" principle. Both of these principles, in my opinion, try to say the same.

Now an example in PHP. Imagine a BlogPost class:

class BlogPost
{
    private Author $author;
    private string $title;
    private string $content;
    private \DateTime $date;

    // ..

    public function getData(): array
    {
        return [
            'author' => $this->author->fullName(),
            'title' => $this->title,
            'content' => $this->content,
            'timestamp' => $this->date->getTimestamp(),
        ];
    }

    public function printJson(): string
    {
        return json_encode($this->getData());
    }

    public function printHtml(): string
    {
        return `<article>
                    <h1>{$this->title}</h1>
                    <article>
                        <p>{$this->date->format('Y-m-d H:i:s')}</p>
                        <p>{$this->author->fullName()}</p>
                        <p>{$this->content}</p>
                    </article>
                </article>`;
    }
}
Enter fullscreen mode Exit fullscreen mode

What's wrong here? The BlogPost class does too many things, and as we know it should do one thing. The main problem here is that it is responsible for printing to various formats, json, html and more if needed. So let's see how this could be improved.

We remove printing methods from the BlogPost class, the rest remains unchanged. And we're adding a new PrintableBlogPost interface. With a method that can print a blogpost.

interface PrintableBlogPost
{
    public function print(BlogPost $blogPost);
}
Enter fullscreen mode Exit fullscreen mode

Now we can implement this interface in as many ways as we need:

class JsonBlogPostPrinter implements PrintableBlogPost
{
    public function print(BlogPost $blogPost) {
        return json_encode($blogPost->getData());
    }
}

class HtmlBlogPostPrinter implements PrintableBlogPost
{
    public function print(BlogPost $blogPost) {
        return `<article>
                    <h1>{$blogPost->getTitle()}</h1>
                    <article>
                        <p>{$blogPost->getDate()->format('Y-m-d H:i:s')}</p>
                        <p>{$blogPost->getAuthor()->fullName()}</p>
                        <p>{$blogPost->getContent()}</p>
                    </article>
                </article>`;
    }
}
Enter fullscreen mode Exit fullscreen mode

You can see a whole example of bad and good implementation here

I've seen projects where classes only have one public method with a few lines of code (usually call to a different method from a different class). Completely illegible and terrible to maintain. In my opinion, this is an example of going too far.

To sum up. Your classes and methods shouldn't be responsible for a few things. But the point here is not to go to extremes and exude absolutely everything. Just to make them easy to understand, but they also have to be consistent. So that you don't have to read them cover to cover to understand what they are doing.

Open/closed principle (OCP)

Open/closed principle (OCP)

Second, from SOLID principles. The general explanation is that “code should be open for extension, but closed for modification”. It is not obvious to me what this means in practice. Perhaps it is better explained by the consequence of not following this rule. Changing the declaration of a method may cause it to malfunction somewhere it is used. The main point is that the changes have to be backward compatible. Of course, it's best to write code that works perfectly from the beginning and you don't have to change it, but we don't live in a perfect world.

I will try to present it with some examples:

a) open/closed API

This will be an example of the open/closed principle not on a single class but on the entire API. It's a large SaaS, it is an accounting system written in PHP, Symfony Framework. Your API is used by several hundred customers who use it to issue invoices. Your API has a method to retrieve invoices as PDF. Let's say it is an endpoint like “GET /invoice/{id}/print”. Everything is fine, but one day customers demand the option to download CSV (everyone from business loves tables).

So you implement this capability quickly and change the endpoint from:

"GET /invoice/{id}/print"

to

"GET /invoice/{id}/{format}"

where the format can be PDF or CSV.

Now only hundreds of programmers using your API have to change how they download the report in PDF. Well, no, it shouldn't be done that way. How to do it correctly? Unfortunately, it is sometimes necessary to see potential problems and anticipate possible future changes. From the beginning, your endpoint did not follow the open/closed principle because it was not closed for modification. Your endpoint should assume that the needs of other formats may arise someday.

b) open/closed animals

Another example, a more classic one. Let's say we have several different animal classes:

class Dog
{
    public function bark(): string
    {
        return 'woof woof';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Duck
{
    public function quack(): string
    {
        return 'quack quack';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Fox
{
    public function whatDoesTheFoxSay(): string
    {
        return 'ring-ding-ding-ding-dingeringeding!, wa-pa-pa-pa-pa-pa-pow!';
    }
}
Enter fullscreen mode Exit fullscreen mode

And a class that allows animals to communicate:

class Communication
{
    public function communicate($animal): string
    {
        switch (true) {
            case $animal instanceof Dog:
                return $animal->bark();
            case $animal instanceof Duck:
                return $animal->quack();
            case $animal instanceof Fox:
                return $animal->whatDoesTheFoxSay();
            default:
                throw new \InvalidArgumentException('Unknown animal');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Is the Communication class open for extension and closed for modification? To answer this question, we can ask it differently. Are we able to add a new animal class without changing the existing code? No. Adding a new animal class would necessitate the modification of the switch in the communicate() function. So what should our code look like to comply with our principle? Let's try to improve our classes a bit.

We can start by adding an interface Communicative, and using it in our classes.

interface Communicative
{
    public function speak(): string;
}
Enter fullscreen mode Exit fullscreen mode
class Dog implements Communicative
{
    public function speak(): string
    {
        return 'woof woof';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Duck implements Communicative
{
    public function speak(): string
    {
        return 'quack quack';
    }
}
Enter fullscreen mode Exit fullscreen mode
class Fox implements Communicative
{
    public function speak(): string
    {
        return 'ring-ding-ding-ding-dingeringeding!, Wa-pa-pa-pa-pa-pa-pow!';
    }
}
Enter fullscreen mode Exit fullscreen mode

After that, we can change the Communication class so that it complies with the open/close principle.

class Communication
{
    public function communicate(Communicative $animal): string
    {
        return $animal->speak();
    }
}
Enter fullscreen mode Exit fullscreen mode

How to code according to the opened/closed principle?

In code, it is worth using interfaces and sticking to them. However, if you need to change something, consider the decorator pattern.

A class or method should be small enough and have one specific task so that no future event can necessitate modification (single responsibility principle). But you also need to consider whether there may be a need for changes in the future, such as a new response format or an additional parameter, your code should be closed for modification.

Liskov substitution principle (LSP)

Liskov substitution principle (LSP)

Substitution principle applies to well-designed class inheritance. The author of this principle is Barbara Liskov. The principle says that we can use any inheriting class in place of the base class. If we implement a subclass, we must also be able to use it instead of the main class. Otherwise, it means that inheritance has been implemented incorrectly.

There are some popular examples of the Liskov substitution principle in PHP:

a) rectangle-square

The first example. We already have a Rectangle PHP class. Now we're adding a Square PHP class that inherits the Rectangle class. Because every square is also a rectangle :). They have the same properties, height and width.

The height of the square is the same as the width. So, setHeight() and setWidth() will set both (what about single responsibility?) of these values:

class Square extends Rectangle
{
    public function setWidth(int $width): void { 
        $this->width = $width;
        $this->height = $width;
    }

    public function setHeight(int $height): void {
        $this->width = $height;
        $this->height = $height;
    }
}
Enter fullscreen mode Exit fullscreen mode

Is that a good solution? Unfortunately, it does not follow the Liskov substitution principle. Let's say there is a test that computes the area of a rectangle, and it looks like this:

public function testCalculateArea()
{
    $shape = new Rectangle();
    $shape->setWidth(10);
    $shape->setHeight(2);

    $this->assertEquals($shape->calculateArea(), 20);

    $shape->setWidth(5);
    $this->assertEquals($shape->calculateArea(), 10);
}
Enter fullscreen mode Exit fullscreen mode

According to the Liskov substitution principle, we should be able to replace the Rectangle class with the Square class. But if we replace it, it turns out that the test does not pass (100 != 20). Overriding the setWidth() and setHight() methods broke the Liskov substitution rule. We should not change how the parent class's methods work.

So what is the correct solution? Not every idea from "reality" should be implemented 1:1 in code. The Square class should not inherit from the Rectangle class. If both of these classes can have a computed area, let them implement a common interface, and not inherit one from the other since they are quite different.

You can see an example solution here

b) live duck vs toy duck

Imagine a living duck and a toy duck and their representations in the code (PHP classes). Both of these classes implement the TheDuck interface.

interface TheDuck
{
    public function swim(): void;
}
Enter fullscreen mode Exit fullscreen mode

We also have a controller with the action swim().

class SomeController
{
    public function swim(): void
    {
        $this->releaseDucks([
            new LiveDuck(),
            new ToyDuck()
        ]);
    }

    private function releaseDucks(array $ducks): void
    {
        /** @var TheDuck $duck */
        foreach ($ducks as $duck) {
            $duck->swim();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

But after calling this action ToyDuck doesn't swim. Why? Because to make it swim, you must first call the "turnOn()" method.

class ToyDuck implements TheDuck
{
    private bool $isTurnedOn = false;

    public function swim(): void 
    {
        if (!$this->isTurnedOn) {
            return;
        }

        // ...
    }
}

Enter fullscreen mode Exit fullscreen mode

We could modify the controller action and add a condition that we call turnOn() on the ToyDuck instance before swim().

private function releaseDucks(array $ducks): void
{
    /** @var TheDuck $duck */
    foreach ($ducks as $duck) {
        if ($duck instanceof ToyDuck) {
            $duck->turnOn();
        }

        $duck->swim();
    }
}
Enter fullscreen mode Exit fullscreen mode

It violates the Liskov substitution principle, because we should be able to use a subclass without knowing the object, so we cannot condition by subclasses (it also violates the open/close principle - because we need to change the implementation).

Handling a collection of objects of a given base class may not require checking whether the given object is an instance of subclass X and should be treated differently.

What should it look like correctly? Common interface for both of these ducks is not a good idea, their operation is completely different, even though we think they both work similarly because they are swimming, it is not.

c) ReadOnlyFile

And the last example. We have a File class with methods read() and write().

class File
{
    public function read()
    {
       // ...
    }

    public function write()
    {
       // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

We're adding a new class - ReadOnlyFile.

class ReadOnlyFile extends File
{
    public function write()
    {
        throw new ItsReadOnlyFileException();
    }
}
Enter fullscreen mode Exit fullscreen mode

The ReadOnlyFile class inherits from the File class. In the ReadOnlyFile class, the write() method will throw an Exception, because you cannot write to a read-only file.

This is a poorly designed abstraction, the Liskov rule has been broken because we are unable to use the ReadOnlyFile class instead of File.

Interface segregation principle (ISP)

Interface segregation principle (ISP)

Uncle Bob introduced this principle when he collaborated with Xerox. They couldn't cope with the ever-long process of implementing changes to their code. The rule is: “No client should be forced to depend on methods it does not use”. The user of the interface should not be forced to rely on methods he does not use. We should not use “fat interfaces” that declare multiple methods if any of them could be left unused. Better to have a few dedicated small interfaces than one that is too general. It is also in line with the single responsibility principle.

So let's see a badly written code, not following the interface segregation principle. I present to you the Exportable, PHP Interface. An interface that allows you to export something to PDF and export something to CSV. We also have an Invoice and a CreditNote class.

interface Exportable
{
    public function getPDF();
    public function getCSV();
}
Enter fullscreen mode Exit fullscreen mode
class Invoice implements Exportable
{
    public function getPDF() {
        // ...
    }
    public function getCSV() {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode
class CreditNote implements Exportable
{
    public function getPDF() {
        throw new \NotUsedFeatureException();
    }
    public function getCSV() {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

We can download the Invoice in PDF and CSV. We can download a CSV of the CreditNote. But downloading the PDF of the CreditNote was a useless functionality and was not implemented (it’s throwing an exception right now).

We shouldn't force our interface implementations to implement methods they don't use. In the above case, we forced the CreditNote class to do so, it implements the getPDF() method even though it does not need it at all.

So how should it look to be good?

According to the interface segregation principle, we have to separate the interfaces. We divide Exportable, and create an interface ExportablePdf and create an interface ExportableCSV.

interface ExportablePdf
{
    public function getPDF();
}
Enter fullscreen mode Exit fullscreen mode
interface ExportableCSV
{
    public function getCSV();
}
Enter fullscreen mode Exit fullscreen mode
class Invoice implements ExportablePdf, ExportableCSV
{
    public function getPDF() {
        //
    }
    public function getCSV() {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode
class CreditNote implements ExportableCSV
{
    public function getCSV() {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

This way, CreditNote no longer has to worry about implementing not used the getPDF() public function. If necessary in the future, just need to use a separate interface and implement it. As you can see here, specific interfaces are better.

The example about ReadOnlyFile related to the Liskov principle is also a good example for the Interface segregation principle. There, the File class has been doing too many things, it's better to have separate interfaces for each action.

That's interface segregation, easy.

Dependency inversion principle (DIP)

Dependency inversion principle (DIP)
Last form SOLID principles, this rule is:

  • High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).
  • Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

What does it mean? We should reduce dependencies to specific implementations, but rely on interfaces. If we make any change to the interface (it violates the open/close principle), this change necessitates changes in the implementations of this interface. But if we need to change a specific implementation, we probably don't need to change our interface.

To illustrate the problem, let's go over this PHP example.

class DatabaseLogger
{
    public function logError(string $message)
    {
        // ..
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we have a class that logs some information to the database. Now use this class.

class MailerService
{
    private DatabaseLogger $logger;

    public function __construct(DatabaseLogger $logger)
    {
        $this->logger = $logger;
    }

    public function sendEmail()
    {
        try {
            // ..
        } catch (SomeException $exception) {
            $this->logger->logError($exception->getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here is the PHP class that sends e-mails, in case of an error, error details are logged to the database using the logger we have just seen above.

It breaks the principle of dependency inversion. Our e-mail sending service uses a specific logger implementation. What if we want to log information about errors to a file or to Sentry? We will have to change MailerService. This is not a flexible solution, such a replacement becomes problematic.

So what should it look like?

According to this principle, MailerService should rely on abstraction rather than detailed implementation. Therefore, we are adding the LoggerInterface interface.

interface LoggerInterface
{
    public function logError(string $message): void;
}
Enter fullscreen mode Exit fullscreen mode

And we use it in our DatabaseLogger:

class DatabaseLogger implements LoggerInterface
{
   public function logError(string $message): void
   {
       // ..
   }
}
Enter fullscreen mode Exit fullscreen mode

Now, we can take advantage of Symfony Dependency Injection.

class MailerService
{
    private LoggerInterface $logger;

    public function sendEmail()
    {
        try {
            // ..
        } catch (SomeException $exception) {
            $this->logger->logError($exception->getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In this way, we can freely replace the logs in the database with logs wherever we want, as long as the detailed implementation implements the LoggerInterface. This change will not require modifying MailerService, because it does not depend on it, it depends only on the interface.

SOLID

All these principles come together as one, they often overlap. It's nice when you know the theory like SOLID principles because it makes it easier to make good code. Then you also have strong arguments behind your code, for example in code review. All the rules are aimed at making the code easy to understand and maintain.

SOLID is one of the many good practices that help us write clean code. I've written about the Boy Scout Rule before. But that's not all, there are many other rules and standards to follow. Let me just mention them:

  • PSR (PHP Standards Recommendations) - PHP Framework Interop Group (PHP-FIG) is a group of people associated with the largest PHP projects who jointly develop PSR. I think every PHP programmer should know coding styles standards PSR-1 and PSR-12 (formerly PSR-2). You can find all the current sets of standards here
  • KISS (Keep It Simple Stupid) - Don't complicate the code. The code should be its documentation itself. Any new programmer on the team should be able to get into the project quickly.
  • DRY (Don’t Repeat Yourself) - Do not code using the Copy-Paste principle (there is no such rule). See that the same code repeats in several places? Extract code for a separate function.
  • YAGNI (You Aren’t Gonna Need It) - 17th-century German philosopher Johannes Clauberg formulated a principle called Occam's Razor (I was also surprised Ockham was not its author ;) ) “entities should not be multiplied beyond necessity". I think this sentence expresses the YAGNI principle well. We should not write code “for the future”. Such code is not needed at the moment.
  • GRASP (General Responsibility Assignment Software Patterns) - is a large set of rules about which I could write a separate article. These are the basic principles that we should follow when creating object design and responsibility assignments. It consists of: Information Expert, Controller, Creator, High Cohesion, Low Coupling, Pure Fabrication, Polymorphism, Protected Variations, Indirection.

Applying the SOLID principles in our daily work helps us not get into technical debt. What are the consequences of incurring technical debt, you can find out in the article written by our CEO Piotr.

If you have problems understanding your project. Write to us, we have experience in dealing with difficult cases and PHP refactoring.

Top comments (1)

Collapse
 
ngohuunhut profile image
Nhut Ngo

Thank you so much <3