Over the last few years we’ve upgraded a few legacy PHP application to be built with newer standards, PHP versions and libraries including moving over to dependency injection (DI). I’ll discuss why I think DI is one of the most important parts of modernising a legacy application.
Upgrade don’t rebuild
First off I’m of the opinion that you should upgrade most software and NOT rebuild. Rebuilding comes with a massive amount of unknowns and for anything that isn’t really small just doesn’t make sense for most business cases. I’m sure there are a lot of blogs available with your bias to read on this topic from a quick Google so won’t delve into it here.
When I originally started our first legacy upgrade I read a great book by Paul M Jones called “Modernizing Legacy Applications in PHP”. The book is now free which is great and while it is probably dated in the tech mentioned the concepts and mostly importantly the process in upgrading is still absolutely solid. If this article does interest you and want more details certainly go read the book as it will fill in a lot of the gaps.
In the book there are quite a few steps and while completing all of them might be a long process depending on the business, budget and time. But the two that made a massive difference were implementing dependency injection and bootstrap/routing. Both sort of go hand in hand in a way as one enables the other to be implemented without a mass amount of code.
Change your mindset with bootstrapping
What do I mean by bootstrap/routing? Typically in a legacy app it was common to have either a main entry point (eg. index.php) that had includes or some way to determine the file to include/run, typically this was in the form of some ugly switch or if statement. The other was to have your pages all publically available (eg. categories.php, orders.php).
When upgrading you’ll generally want to move to some hybrid approach using a router for specific URL’s and a fallback to non-upgraded files using some dynamic include. This removes all php files from being public except the entry point which helps later on. This also provides a good way to slowly upgrade code without it feeling like it’s an all or nothing job, it’s also a great quick win towards modernisation. Upgrading software can be extremely tiring without exciting features to work on and show off so having mini goals is important to keep the motivation and momentum in the upgrade project.
A benefit of this process is that you can still add new features as well hopefully in the new method and proivides a good opportunity to upgrade any component/services around the feature. It doesn’t feel great when your writing code that is technical debt from day 0.
Let’s inject!
With the routing hopefully cleaner now you’ll be able to focus on making your classes and objects built with DI in mind. While most DI discussions in PHP are typically focused on automatic wiring this is NOT required. If your not sure on what DI really is there are a heaps of articles by much smarter devs than myself out there, for a quick rundown…. let’s start with what it isn’t, as service locator. This is where you pass around an object that can create other objects, yeah don’t do that please it’s a massive anti-pattern. What DI does is make sure all services are created in the “composition root” just a fancy word for saying the entry or router file and that is our aim, we don’t want any “new” for services inside any classes as that means they aren’t being injected. See below for a example of what you want to remove
class SpaghettiEmailer { public function send($whoBro) { $service = new AwesomeService('straya'); $service->consumeMethod($fromBro); } }
so we want to remove the new and push into the constructor like:
class SpaghettiEmailer { public function __construct(private AwesomeService $service) { } public function send($whoBro) { /* do some other things here */ $this->service->consumeMethod($fromBro); } }
Above is using PHP 8.0’s newer Constructor Property Promotion feature which is great.
I think the best first step is taking one section/module of your system and organise the code with seperation of concerns, inversion of control and testability in mind. This isn’t always straight forward and you might need to create more classes and possibly some factories to make it all flow together but in the end you should be able to initiatialise a class with just the constructor and no other services should be created within the class, I do say services here as you can create pure data objects without breaking the rules (not sure everyone agrees on this). Also try avoid having any runtime data required to initialise a service, this will likely have to go through a factory but I always try to avoid having too many factories as most of the time they are not required with better structure (a bit of trial and error normally helps), for now though they are a good tool to reach our goal.
If your constructors are getting a bit unwieldly it likely means you need to refactor your classes a bit more, but in the first phase while it’s not the best, getting momentum on this change is the main thing while trying to not breaking everything. It can be quite mentally taxing doing these changes and sometimes you realise a much larger problem with the code structure that might need to be put on hold. Use your best judgement here, sometimes an anti-pattern or bad code can help progress, again momentum is key when doing this and over time it will become more clear how to organise your code.
Once you have a module that can be DI’ed you’ll probably notice there is quite a bit of repeating code to initialise a service and it can feel a bit tedious.
$config = new ConfigProvider(); $dbProvider = new PostgresProvider($config); $emailLoggerRepository = new EmailLoggerRepository($dbProvider); $emailProvider = new SendGridProvider($config); $emailService = new EmailService($emailProvider,$emailLoggerRepository,$config); $emailService->send(...);
In the example above the final email service has it’s dependencies all injected but the required dependency tree can be quite large sometimes
Using a Di container
There are three levels to DI
- Manual injection (as above)
- DI container
- DI autowiring
The end goal should be to get to the autowiring but aim for a DI container as soon as possible. Doing this still has a manual step as you need to configure all your dependencies but you only have to do it once saving on code and time. I think spending a bit of time manually configuring these are good depending on the team/developer as it provides time for the concepts of DI to sink in and forces them to handle good and bad implementations. Jumping straight to autowiring is nice but I think hides a bit of the magic that makes it hard to diagnose if you don’t understand what is happening under the hood.
Here’s an example that is a bit cut down so there isn’t TOO much code to review. It does take a bit more effort once but you get the benefits straight away once you start consuming the services.
$di = new Di\Container(); $di->set('ConfigProvider', function () use ($di) { return new ConfigProvider(); }); ... set the other services ... // set the email provider using the existing config we set earlier $di->set('SendGridProvider', function () use ($di) { return new SendGridProvider( $di->get('ConfigProvider') ); }); // set the email service now using the other DI settings $di->set('EmailService', function () use ($di) { return new new EmailService( $di->get('SendGridProvider'), $di->get('EmailLoggerRepository'), $di->get('ConfigProvider') ); }); // when we want the email service now it's much easier $emailService = $di->get('EmailService');
Good job, now with a DI container to initialise your classes hopefully you are closer to having code that while might still be a bit nasty has core business logic mostly in classes that can be injected. Depending on the starting point you might still have some nasty spaghetti code/views or random .php pages calling these (yeah that’s fine for now). This means you can start working towards the hard part… moving to an MVC or ADR (action domain responder) pattern for your pages. Won’t go into that here but by having the DI part sorted provides a much better base to build on.
Once you start using MVC/ADR or alternative this is when autowiring will become more important as it will save on time configuring each dependency and force your code to conform to better standards. This will eventually replace your DI setup and the autowiring configuration will be around interfaces or code you cannot control. Below is a basic example using the DI service Capsule
// Define the definitions for the DI container $def = new Definitions(); $def->{DbProviderInterface::class} ->class(PostgresProvider::class); $container = new Container($def); // Auto wire a class that depends on the DbProviderInterface // This will automatically get the class defined (PostgresProvider) // and inject that as the dependency $emailLoggerRepo = $container->get(EmailLoggerRepository::class); // With some router and ADR/MVC you might have something more like // which will get the relevant handler for the route being requested $controller = $container->get($handler);
I didn’t touch on testing as this should be it’s own post, the general rule would be to test after every change. Move a service to the DI, test. Split out code and change dependencies, test. I think you get the idea and there is a whole chapter around testing in the book.
Recap, why is DI one of the most important elements? It sets a standard throughout how you view the modernisation. Code seperation, inversion of control, testability, consistency, while not required it helps with all these things and is a good benchmark against to measure progress in a project. Once you get to a point where you can add a new route and the dependencies and dispatch are taken care of it feels good, you’re more efficient in coding and can hopefully execute faster knowing the basics you previously had to baby sit are done for you. We’re paid for outcomes not lines of code.
Another thing to be aware of.. are PHP versions, they come out every year now and even 8.0 will be EOL by the end of 2023! Through this post I’ve used some new code that only works on PHP version 8 or higher. There are security risks running old software along with missing out on newer features and performance that you get for free… well the cost of keeping your software up to date.
For most PHP applications upgrading to a newer version of PHP might not be too hard depending on 3rd party dependencies and should be a top priority. For example Capsule mentioned above requires PHP 8+ and we’ve found more and more packages requring higher versions, there is a balance here but with a good editor/LSP it shouldn’t take too long.
I hope this provides some help to anyone upgrading/modernising old software and feel free to hit me up to chat about it.