A decoupled PHP architecture inspired by the Clean Architecture

How we create APIs and workers in PHP that won’t be a pain in a few years.

Joe Santos
Engenharia Arquivei
5 min readSep 2, 2020

--

This article would not be possible without the help of Rodrigo Jardim da Fonseca, Edison Junior, and Lemuel Roberto.

Disclaimer: I feel like I should address that the architecture I’m about to present already existed when I arrived at Arquivei, almost two years ago and didn’t change much since. I didn’t create it, but I did learn a lot from it. Hopefully, you will too.

I don’t think I need to sell you on the idea of an architecture that enables your team to build decoupled solutions, but if I do, here are the basics:

  • Separation of concerns: you get pieces of software that are easier to understand (and maintain), not only because of their size but also because each piece only does one job.
  • Testability: automated tests (especially unit tests) are essential to ensure quality in delivery. The trend for continuous deployments with shorter intervals between them made it almost impossible for an application to be successful if it’s hard to create tests for it.

With that in mind, when you look for a solution you’re likely to land on the well-known pages of Alistair Cockburn’s Hexagonal Architecture and Jeffrey Palermo’s Onion Architecture. If your journey is anything like ours, you may also find yourself reading about the five SOLID principles and Uncle Bob’s Clean Architecture, that pretty much sums it all. Those will give you a theoretical foundation to build what you need and it may look very different from what I’m about to show you, but the problem of a decoupled architecture is not a problem with a single solution. So, how do we do it at Arquivei?

First of all, our application is divided into two layers: app and core. The app layer holds all vendor-specific code: infrastructure, adapters, and framework utilities while in the core layer you will find pure PHP code that handles our business logic. Most changes will affect the contents of app, but only changes in requirements should affect the code living in core.

One important aspect of this division is that all contracts are specified by the core layer: gateways, requests, responses, and entities. The pieces on the app side will follow these contracts by implementing interfaces and handling the entities to produce the result expected from them.

Figure 1: The two layers. The code inside app contains controllers that will interact with the UseCase class through requests and responses. Adapters and repositories will implement gateways to perform actions as specified. Modules will contain all code required by the use case. Not all folders are required and others may be added if the case uses a pattern not mentioned here. Inside Dependencies you will find code that can be used by any module and requires a third party implementation, while the code inside Packages does not rely on a third party to provide code useful for all modules.

The main class in the core layer is called UseCase and it represents, well, a use case of the application (creating a user, for example). It is the only point of interaction with the world outside of core. Its input and output are specified by the Request and Response classes respectively. To use it, we need to pass all the dependencies required to the constructor and call its execute() with request. The method returns a response whenever one is needed.

Figure 2: An example of a core folders structure with the user creation module. More modules can be added as required.
Example 1: A UseCase. Note that all dependencies are given to the constructor to avoid errors when executing the request.

Sometimes your use case is not simple. It is very common for us to retrieve data, process it in a few different steps (like generating PDF files and then a ZIP with all of them) before presenting a response. To avoid clutter in the UseCase::execute() method, we use a Ruleset.

The Ruleset class takes however many Rule objects we need (hopefully each with a single responsibility) and orchestrates how the rules are applied. A response is built with the result and returned.

Example 2: A rule set orchestrates how the rules are applied. Dividing the logic into rules allows us to create bite sized pieces of code that are easy to maintain.

The constructor of each rule will take all it needs for the rule to do its job: dependencies and request data and will perform its task. If an exception is encountered, it is to be wrapped in a core-specific exception and thrown. This makes it easier for us to track which part of our application failed. Consider how easier it is to understand what is going on when you come across a UsernameNotAvailableException than a PDOException.

Example 3: A rule that saves a user to the database. Any exception or errors are caught and wrapped ina a specific exception to make debugging easier.

You probably noticed that in the example above that the constructor does not take a database adapter, but a Gateway. Gateways are interfaces that dependencies must implement, that way we know they will follow the contract that the core layer specified and all the effort in switching vendors, for example, will be that of writing a new adapter and changing the calling code (usually a controller) to use it. Also, note that the core layer does not know about the data types returned by your database because it uses the entities defined within itself. App always adapts to core, never the other way around.

Example 4: A gateway and a novelty adapter that implements it. A real adapter would save to the database and throw an exception in case of error. This example is just a simplification that can only “save” in even second times. Nevertheless, it follows the contract determined by the gateway.

The last piece of our puzzle is the testing and with this architecture, it is pretty simple: mock the gateways, create a request, and assert on the response. PHPUnit has utilities to ensure the flow works as expected and even the failure scenarios can easily be tested for 100% code coverage on your business logic. Keep in mind that you still need to be smart about your test cases: coverage is just one way to help you make sure you did everything you needed.

It is also worth noting that some people in our team prefer to build tests for every class as they write tests alongside application code. Nothing wrong with that approach if it works for you.

Example 5: A unit test that covers 100% of the files in the user creation module. While not always being enough to ensure quality, good coverage is still essential and possible with this architecture.

There’s a lot of questions we ask ourselves in our weekly meetings about this architecture and you may have some right now. It can often seem like too much or like it doesn’t fit your problem and we don’t have all the answers. This is never ending work in progress that has helped us achieve a good level of decoupling and made our applications way easier to create and maintain. I hope it gives you some insights.

If you want a more detailed example, you can find it here.

Thanks for reading.

--

--