07 March 2022
How to create a framework-agnostic application in PHP
Creating a framework-agnostic application is possible, no matter what set of tools you use. Recently, PHP developers talk a lot about Domain-Driven Design (DDD) – hexagonal architecture and similar patterns. Their main goal is to separate business logic from a framework, storage and third party-related code. I’d like to discuss this topic further and show you how to separate domain stuff from all necessary boilerplate that is needed to have everything up and running. I’ll use two very popular PHP frameworks – Laravel and Symfony.
Let’s start with explaining the basics.
What is a framework-agnostic application?
Being framework-agnostic means that code containing business rules of your application is carefully separated from framework files. As a result, your domain doesn’t know anything about the framework that is used to handle HTTP related communication. But it’s not only about Laravel/Symfony (or any other framework that pops to your mind). I want it to be ORM/third party libraries agnostic too. Moreover, I don’t want my business logic to be bound to Doctrine’s annotations or Eloquent model. It needs to be clean and fully working on its own.
Why separate an application’s business logic from other elements?
You may think: “It’s ridiculous, I’ll never change the framework or the storage in an existing project.” Really? Are you 100% sure? I’ve been working on a project which is being migrated from Zend Framework 2 to Symfony 4. If the application domain had been separated from the implementing part, it would be A LOT easier. We would’ve just needed to switch implementations for a set of contracts and targeted framework-related classes. As a result, it could’ve saved us a lot of time and hassle.
If you don’t touch anything, you can’t possibly break it. Domain code scattered all over framework related classes can be broken easily during migration. Tests won’t help you because you’ll have to rewrite them all (and you can break them, too).
With a framework-agnostic approach, the domain and its tests are completely independent, so you have full confidence that it’ll work fine.
All potential issues will be related to new tools, without any chance that you’ll accidentally change the application’s behaviour.
Even if you can’t imagine changing storage during the project’s life, it’s possible that you’d like to change the ORM or even get rid of it. If business logic models are not separated from storage libraries, this is usually a very time-consuming task. Not to mention, that people often design domain classes with specific storage in mind, rather than focus on their natural relations. Keeping everything separated can even allow you to use two different storage implementations to check which one better suits your needs.
How to achieve framework-agnostic approach?
It’s not as difficult as it sounds. We will use several techniques to make things easier.
Disclaimer: What I’m gonna use is neither DDD nor hexagonal architecture. We’re focusing on being framework-agnostic, so we will mix concepts from different patterns without using strictly any of them. Finally, my code doesn’t include tests, but they’re omitted on purpose, in order to simplify the whole process.
First of all, when we start designing our application we shouldn’t think about a framework or database at all. Instead, we need to focus on business requirements, its rules and goals. Other things are not that important at this point. When it’s done, we can think about appropriate tools to have it running somewhere on the web. It’s time to introduce the example domain that I’ll use. Please welcome…
The Runner App
It’s a very simple web application which helps out the runner with his run management for a competition that he’s participating in. Easy-peasy. So let’s see how the app works.
We’ve got a Run which has its title, date of start, length and type. We also have a Runner that can participate in a Run and if he finishes it, his Result is saved. For the purpose of this article we’re going to implement simple actions:
- Enrol Runner to Run with the following rules:
- if a Run has already started, a Runner cannot be enrolled
- if a Runner is already enrolled for a Run it cannot be done for a second time
- Save Runner Result in Run:
- if a Runner has not participated in a Run, its Result cannot be saved
- if a Result has expired, it cannot be saved (in our example the result can be added up to five days after the run)
- if a Runner reached time limit for the Run, its Result cannot be saved
- if a Result for a Runner is already saved it cannot be done for a second time
These are probably not all the rules that should be used in this case, but for our example, that’s absolutely enough.
Disclaimer: We don’t care about run creation or runner registration here. The example app is simple and makes some assumptions about storage state (there are already runs and runners inside). I don’t want to overcomplicate this example because it’ll become harder to understand.
Having some business knowledge, we can start programming our domain. Let’s begin with a few models:
This is our Run model. As you can see, it’s very simple, just a few properties with a constructor and getters. You may ask, what Id class is? It’s self-validatable class storing UUID – just take a look. There is also RunType class which stores possible types of runs. However, it’s not critical for our example.
The next one is Runner model:
Here we have a few interesting things to explain:
- Runner model uses User which contains simple properties like email or password.
- We have RunParticipation and RunResult classes here that connect our runner with runs and its results in them. I’ll show you one of these classes later.
- I decided to keep the relation between Runner and Run in the first one (you’re free to do the other way, that’s just my design decision).
- We have two juicy business methods here: participate and result. They’re a core of our system and they’re responsible for almost all business logic in this example. As you can see, they contain a simple set of conditions (which are described by our business rules) and throw exceptions if they’re not met. Other than that, they create a new instance of specific relation class, add it to the collection and return for easier processing by other parts of the application.
I’d like to show you an example of a business exception:
As you can see it’s a very simple class containing a specific message. Please notice, that it extends DomainException instead of PHP’s \Exception directly. That gives us more control and helps to treat domain exceptions in a special way.
Now let’s take a look at one of the relation classes, RunResult:
We will use it from the Runner model’s perspective, so it contains Run. Runner id is needed only for easier integration with other layers of the app. It also holds a time result which runner achieved during the competition.
RunParticipation model is very similar. It doesn’t contain additional properties.
Please keep in mind that RunParticipation and RunResult are still a part of the domain, and they have nothing to do with how it will be implemented in the infrastructure layer with a real storage implementation.
Take a look one more time at all these models. They don’t know anything about the framework, storage or third party libraries. They are pure PHP classes containing the essence of our application.
In the domain, we have not only models, but we also need a way of getting our models from the storage. For this purpose, we’re gonna use the Repository pattern. Example repository contracts, that are still part of our domain, are presented below.
With the first one, it’s only possible to retrieve runner from the storage, the second one just saves his participation. We focus on the important elements only!
Now that we have our domain layer explained, it’s time to take a look at the application. In this layer, we’ll use a command bus, so for every application action, there is class responsible for it. In this pattern, we have a very simple Command class containing data introduced by a system actor. Then, it is handled by a Handler that executes specific logic with domain layer’s help. Let’s take a look at runner’s enrolment example:
The command is self-explanatory. It’s just a use case for our application. The Handler uses domain repositories to fetch required models, execute the specified business logic task and saves its result back to storage. What’s worth noticing? A relation between layers: domain doesn’t know anything about the application, but the application uses the domain to perform its tasks. Also, the application is still third-party-tools-agnostic. We didn’t have to choose a framework or storage!
What about frameworks?
And… that’s it! We have a fully working application. There are only a few small obstacles: it can’t handle HTTP/cli requests, doesn’t have storage, so we have no real data to work with. It’s also missing some other things indispensable for frameworks and third-party libraries to make our life easier. So, it’s high time to introduce frameworks!
Framework & storage
Connecting the framework-agnostic app with storage
I decided that in this specific case I’m gonna use a MySQL database. No specific reasons behind that decision – it’s just very popular, most programmers know it pretty well and use it very often.
For starters, we need to prepare Eloquent representation of our domain models. Let’s take a look at domain Runner model one more time:
And now at its Eloquent equivalent:
Notice that Eloquent model contains relations to other storage models. We’ll get back to them later.
Now there’s one critical question: how to connect these two completely different classes? Since they are not similar at all, extending may not really be a good idea, especially because the storage model already extends ORM’s Model class. Well, we have another option. Let’s write a class that will be able to do both directions translation between these two models. Let’s call it a transformer. Here it is:
For now, let’s omit the additional transformers which are injected to handle the other models needed by our runner. We want to focus on methods entityToDomain and domainToEntity. As you can see, thanks to transformer class we can easily connect our domain models with Eloquent and our storage will work just fine.
One more example of Eloquent model based on RunParticipation can be found below.
And now the corresponding transformation class:
As you can see, we’ve got additional entityToDomainMany method. It’s used to translate a whole collection of RunParticipation for a specific runner.
Now we have the possibility to translate models, so we can finally fulfil repository contract. We can use facedes without worrying about coupling concerns because everything is separated. Let’s take a look at RunnerRepository implementation:
It finds data in MySQL and uses a transformer to translate it to the domain’s Runner model.
We can also check repository which is used to save data instead of retrieving it:
It’s very simple, with just two meaningful lines of code. Thanks to “Single Responsibility Principle” most of these classes are quite small and easy to read.
That’s all when it comes to storage! We have connected our business models to ORM and can read/write real data from an external source.
Framework for the framework-agnostic app
Now let’s connect everything to Laravel. Our point of connection is the application layer. I decided, that I don’t want to dilly-dally with HTTP – it’s so common and boring that wanted to try something different, so we’ll be sending requests through CLI. Fortunately, it’ll be a lot easier, than storage preparation. We’re going to use dependency injection whenever it’s possible.
We need to use the command bus implementation. I chose Tactician. There are ready-made packages for Laravel, I used joselfonseca/laravel-tactician.
Here is our participation console command:
We accept two parameters – runnerId and runId. Handle. The method itself is quite simple:
- line 45 – informing command bus which handler should take care of specified command,
- lines 47-48 – creating Id objects, so they will be handled by application command
- line 50 – instantiating command,
- lines 52-58 – dispatching command through command bus and inform about success. If any business rules won’t be met, we catch DomainException and inform the user what was the issue.
You may ask, why haven’t we validated data earlier by using frameworks validators. The truth is, we should have done it, but we’ve skipped it since this application is simplified. You should always validate your data with framework!. Domain exceptions are the last stand, which we normally shouldn’t reach anyway.
We can’t forget about dependency injection though, so here is provider I used:
We need bindings only for our contracts (interfaces), rest is automatically done by Laravel. After this step and writing some database migrations our application is fully working. Not even a piece of logic is depending on a framework or library.
Framework-agnostic means independence
As you can see, being framework-agnostic is not very difficult, we just need to use patterns consciously and add a bit of out-of-box thinking. To prove that our domain and application layers are really independent, I’m gonna reuse them, this time with Symfony and Doctrine. I will describe the process by the end of this article. In the meantime, please take a look at the repository with a working Laravel application.
Switching framework and database tools
Today in our technology stack menu we have:
- Symfony 4.2
- Doctrine (MySQL 5.7)
- Tactician (command bus implementation)
As you can see, it’s not very different from Laravel’s. We’ve only switched framework and database tools. I decided to use Doctrine because this quite mature ORM is very popular amongst developers. Simple as that. So, let’s start.
Firstly, connect framework-agnostic app with storage…
At first glance, you’ll probably notice a big difference between Eloquent and Doctrine. Take one more look at our domain’s Runner model:
…and its Doctrine equivalent,
with the corresponding transformer.
I bet a couple of questions popped into your head: Why not use domain model directly and move doctrine configuration to XML? Why do we need the transformer again? In Eloquent it was definitely needed, but here we have quite a nice and clean class. The answer to both is quite simple: being tools-agnostic!
Unfortunately, Doctrine’s entities aren’t independent at all. To keep relations between classes you are forced to use ArrayCollection class from ORM’s tools. I bet you’d like to operate on simple arrays and definitely don’t want to clutter your domain with external stuff. Hence why you need separate entities and transformers doing a two-way translation. Also, Doctrine may try to force specific things on you (like storing whole related object instead of its id) and transformer may help you keep it under control.
These issues can be easily noticed on the example of RunParticipation entity:
Because of how ORM works, I have to keep the whole Runner entity inside. The domain model needs only runner id, not the whole object. An additional issue is that the Entity Manager needs to track those entities and keep additional information so its “magic” could work. That’s causing a lot of issues when translating from domain to entity. You’d have to fetch all entities from Doctrine’s repository so it can keep track of them. Fortunately, there’s a way to deal with it. Please notice, that I used @Id annotation on both fields in the entity, so we have a compound primary key here. Thanks to that we are being protected from duplicated pairs from both columns.
Let’s take a look at transformer:
Our centre of interest is domainToEntity method. As you can see, I used references from the Entity Manager instead of real entities. I’d have to fetch them from Doctrine’s repository so they could be tracked by ORM. We won’t be using them for anything other than saving data to the database so they’re all that we need. Other than that transformers are quite similar to Laravel ones, they just handle different object representation of tables and columns.
The last thing to see from a storage perspective is the repository. The example below shows read/write repositories.
As you can see, I’m injecting EntityManager to retrieve correct Doctrine repository and transformer. I decided not to use Doctrine repositories directly because I still needed other dependencies.
How would I compare Doctrine and Eloquent from this perspective? Generally, I prefer Doctrine’s ORM, as it has, in my opinion, clearer models and is a bit easier to use. However, in this specific case, its restrictions and rules were more difficult to follow than Eloquent ones.
Now the framework-agnostic app needs a framework
Well, this part won’t differ a lot from Laravel. Check out this out – I moved the whole Symfony code to the infrastructure part. Again we are doing it in CLI to have a proper comparison. For Symfony thephpleague/tactician-bundle package is available.
Now it’s time to check console command class:
As you can see, the main logic is exactly the same as in Laravel – you fetch console arguments, pass it to command and let command bus handle it. You probably noticed that I’m not connecting command with a bus in this class anymore. It is done automatically by Tactician thanks to its typehint feature (command is resolved based on handler’s argument).
Below you can find the part of the configuration that allowed me to achieve it:
Other than that I’m using autowiring and autoconfiguration, so as many things as possible could be configured automatically. Please check full fa-runner implementation at The Software House’s GitHub.
See also: SQL queries, practice and excercises
The final conclusions
Throughout the article, we haven’t touched our domain at all, just used different tools around. Thanks to that there were no doubts, that main logic will work fine (although some test wouldn’t hurt – just in case 😉). I’m aware that code I provided you with is not perfect and there is a room for improvement (for example database schema is different for Eloquent/Doctrine, although transition shouldn’t cause any data loss), but I achieved my goal: framework-agnostic application wrapped with completely different tools.
See also: How to build an API?
I hope, that this article helped you to understand how separation from external tools can be done in PHP. But! I’m not going to leave you without homework. If you’re interested, please try to take my domain and use Zend Framework and Propel to practice a bit more. Good luck!