Skip to content
Davor Minchorov

Building Maintainable PHP Applications: Framework Decoupling vs Framework Coupling

This article is part of the Building Maintainable PHP Applications series

All frameworks are opinionated general solutions that try to help us build a product which save us time so that we don’t have to write code manually and we can focus on the real problem which is solving business related problems and automating the processes.

As developers learn about a framework that they want to use or love, they start writing code which is using the framework to the fullest which means they are depending on the framework to drive the development and solving the problem.

Same goes for the folder structure, which is very generic and goes with the “folder by type” structure instead of “folder by feature”.

This is what’s called framework coupling or coupling yourself to the framework.

All of the projects I’ve worked on as well as most of the code examples I’ve seen on the internet has this problem of framework coupling where the framework dictates the project how it’s built and the business is heavily dependent on it.

Why is this a problem you may ask?

There are a few reasons why:

  • Whenever the product grows, the tool and language upgrades slow down and become harder to upgrade when the direct implementation is being used from the framework

  • It will take time for the project to be upgraded to the latest version of the framework or other libraries

  • The risk of breaking the product is higher due to the required changes which affect the whole product

  • It takes more time to implement the changes and test all of them

Here’s a code example of framework coupling (the structure of the code and the framework in the example does not matter here):

1<?php
2 
3class UserReservationController extends Controller
4{
5 public function create()
6 {
7 abort_unless(auth()->user()->tokenCan('reservations.make'),
8 Response::HTTP_FORBIDDEN
9 );
10 
11 $data = validator(request()->all(), [
12 'office_id' => ['required', 'integer'],
13 'start_date' => ['required', 'date:Y-m-d', 'after:today'],
14 'end_date' => ['required', 'date:Y-m-d', 'after:start_date'],
15 ])->validate();
16 
17 try {
18 $office = Office::findOrFail($data['office_id']);
19 } catch (ModelNotFoundException $e) {
20 throw ValidationException::withMessages([
21 'office_id' => 'Invalid office_id'
22 ]);
23 }
24 
25 if ($office->user_id == auth()->id()) {
26 throw ValidationException::withMessages([
27 'office_id' => 'You cannot make a reservation on your own office'
28 ]);
29 }
30 
31 if ($office->hidden || $office->approval_status == Office::APPROVAL_PENDING) {
32 throw ValidationException::withMessages([
33 'office_id' => 'You cannot make a reservation on a hidden office'
34 ]);
35 }
36 
37 $reservation = Cache::lock('reservations_office_'.$office->id, 10)->block(3, function () use ($data, $office) {
38 $numberOfDays = Carbon::parse($data['end_date'])->endOfDay()->diffInDays(
39 Carbon::parse($data['start_date'])->startOfDay()
40 ) + 1;
41 
42 if ($office->reservations()->activeBetween($data['start_date'], $data['end_date'])->exists()) {
43 throw ValidationException::withMessages([
44 'office_id' => 'You cannot make a reservation during this time'
45 ]);
46 }
47 
48 $price = $numberOfDays * $office->price_per_day;
49 
50 if ($numberOfDays >= 28 && $office->monthly_discount) {
51 $price = $price - ($price * $office->monthly_discount / 100);
52 }
53 
54 return Reservation::create([
55 'user_id' => auth()->id(),
56 'office_id' => $office->id,
57 'start_date' => $data['start_date'],
58 'end_date' => $data['end_date'],
59 'status' => Reservation::STATUS_ACTIVE,
60 'price' => $price,
61 'wifi_password' => Str::random()
62 ]);
63 });
64 
65 Notification::send(auth()->user(), new NewUserReservation($reservation));
66 Notification::send($office->user, new NewHostReservation($reservation));
67 
68 return ReservationResource::make(
69 $reservation->load('office')
70 );
71 }
72}

While these things don’t seem like a big deal, they usually are in the real world. Many people may not talk about it publicly, they may not know how to deal with some of the problems that they’ve encountered or they just haven’t yet experienced a scenario where this has been a problem.

I’ve witnessed a whole team of 5 or more people upgrading Laravel from version 5.3 to 5.8 in a few months, including testing it manually on separate servers due to the size of the fintech product I was working on. Now imagine how much money and time was spent on the upgrades. Most likely thousands of dollars if I had to guess.

In order to avoid these problems and make it easier, there are methods where the idea of using interfaces instead of direct implementations everywhere comes to mind.

The idea is that you are not using the implementation class all over the place in the codebase but rather use interfaces, and then you tell the container which interface should inject which implementation class as a dependency and where should that happen, in case a specified context is required (Example: only use the S3 file upload implementation in these 3 use cases).

This also helps with building custom components and making your business requirements take charge and lead the way the code is written instead of the framework itself.

This may sound like a crazy idea: “why would anyone want to build their own packages when we have those as framework components out of the box?”, but remember that most of the components you see in a framework are usually generic solutions and they can’t be implemented specifically for the business you are working on.

Sometimes some components may be slower in specific scenarios so rolling your own modified component may be a good idea. (examples of this would be heavy data processing and serving heavy amount of traffic).

Now, I am not suggesting that you should build your own framework here, even though there’s a whole frameworkless movement out there who would rather piece together a few packages from different PHP communities like Symfony and Laminas, but be aware of which packages you use.

Some of the changes you may need in some of the packages may get declined so you would have to rework the implementation slightly to fit the business requirements.

Packages and frameworks do get stale, may not be updated or even go into a different direction than what you expected them to so it would take you a lot more effort to rework the implementation in your own way and do a bunch of changes everywhere compared to when you just switch the implementation details behind the scenes.

The good thing is that don’t need to touch your main business related code or business logic, it will be isolated from your framework of choice.

Testing is another huge benefit here, you will be able to test your use cases without depending on framework components that need to be booted.

You are working with pure PHP objects in your business related code, that means:

  • Tests will be faster

  • Testing units of code independently is possible

  • Custom testing specific implementation can be implemented

  • Modelling the business use cases without framework / infrastructure components is possible, meaning you wouldn’t need to setup a bunch of things in order for the unit tests to run and to design how the objects will interact with each other

You will still have tests that will require to test the actual implementation of all components that will be used in production (things like database(s), file system(s), external HTTP calls etc.) but those tests will be separate and can be executed less often due to their slowness compared to the unit tests.

This is what we call framework decoupling or decoupling from the framework.

Here’s an example of how framework decoupled code may look like (the structure of the code and the framework in the example does not matter here):

1<?php
2 
3declare(strict_types=1);
4 
5namespace App\Application\Members;
6 
7use App\Application\EventDispatcher\EventDispatcher;
8use App\Domain\Members\EmailAddress;
9use App\Domain\Members\EmailAddressIsAlreadyTaken;
10use App\Domain\Members\FirstName;
11use App\Domain\Members\Id;
12use App\Domain\Members\LastName;
13use App\Domain\Members\Member;
14use App\Domain\Members\MemberRepository;
15use App\Domain\Members\Password;
16use App\Domain\Members\StatusName;
17use Assert\AssertionFailedException;
18use Illuminate\Contracts\Hashing\Hasher;
19use Symfony\Component\Clock\ClockInterface;
20 
21final readonly class SignUpMemberCommandHandler
22{
23 public function __construct(
24 private MemberRepository $memberRepository,
25 private EventDispatcher $eventDispatcher,
26 private ClockInterface $clock,
27 private Hasher $hasher
28 ) {
29 }
30 
31 /**
32 * @throws EmailAddressIsAlreadyTaken|AssertionFailedException
33 */
34 public function handle(SignUpMember $signUpMember): void
35 {
36 $emailAddress = EmailAddress::createFromString(value: $signUpMember->emailAddress);
37 
38 $emailAddressExists = $this->memberRepository->existsByEmailAddress(emailAddress: $emailAddress->getValue());
39 
40 if ($emailAddressExists) {
41 throw new EmailAddressIsAlreadyTaken();
42 }
43 
44 $member = Member::signUp(
45 id: Id::createFromString(value: $this->memberRepository->generateIdentity()),
46 firstName: FirstName::createFromString(value: $signUpMember->firstName),
47 lastName: LastName::createFromString(value: $signUpMember->lastName),
48 emailAddress: $emailAddress,
49 status: StatusName::PENDING,
50 createdAt: $this->clock->now(),
51 updatedAt: $this->clock->now(),
52 password: Password::createFromString(value: $this->hasher->make(value: $signUpMember->password))
53 );
54 
55 $this->memberRepository->save(member: $member);
56 
57 $this->eventDispatcher->dispatchMultiple(events: $member->releaseEvents());
58 }
59}

I understand that it’s easier to build products with the framework as a first class citizen but that codebase won’t be easy to maintain over time.

This may seem like a useless complexity when you first read it, but remember, this also very much depends on the context you are in, the team’s experience with writing code and thinking this way, the budget of the product etc. but it does help when you have more control over your code in the long run.

“Why would you use a framework if all you will do is end up not using it?”

You will use the components of the framework, not all of them and not by the framework’s convention, which means the framework will still be useful to you, it will just be different to how you write code and how you think.

This way of working and thinking requires a lot of time to get used to, but it’s worth experimenting with some of these ideas on personal projects before trying to even use it on client projects.

I am aware that this may not make much sense to you and it may only be useful in some scenarios, but it won’t hurt to know that there’s a different approach to writing code and thinking which may become useful if you ever need it.

I’ll get back to this topic later in the series with some more detailed examples where this will make more sense.