I've been a big fan of Symfony components for ages. I use them as core bricks of my projects migrate other frameworks to it, and every 6 months, I'm excited about what new features are coming in the next minor release.
But, one tough spot has been bothering me for the last 4 years. I tried to find my way out of it, hack around it or accept it. In March 2021, we downgrade Rector 0.10 from PHP 8 to 7.1, and the issue became visible more than ever.
I knew there was a time for a change.
The command-line application is anything we run from CLI - PHPUnit, PHPStan, Rector, Composer, or ECS. At the start of building such an application, you stand in front of an important decision:
use Symfony\Component\Console\Application;
$application = new Application();
$fileAnalyzer = new FileAnalyzer();
$application->addCommand(new ProcessCommand($fileAnalyzer));
$application->run();
use Symfony\Component\Console\Application;
$application = $container->get(Application::class);
$application->run();
I grew up on DI containers, so I'm spoiled by the automatic injection and service management this pattern handle for me. That's why I picked symfony/http-kernel
to build console application.
symfony/dependency-injection
?The name seems quite fitting, right? We could provide a single config file, and that's it. Unfortunately, the name is a bit misleading. It does not handle compiler passes, extensions, service autodiscovery, container cache, and container build. You'll find most of the building bricks there, but the glue is missing.
There is no "container factory" class in symfony/dependency-injection
, that would handle even the simplest use case:
use Symfony\Component\DependencyInjection\ContainerFactory;
use Symfony\Component\Console\Application;
$containerFactory = new ContainerFactory();
$container = $containerFactory->createFromConfigs([__DIR__ . '/config/config.php']);
$application = $container->get(Application::class);
On the other hand, there is an "optional dependency" on symfony/http-kernel
package. Some Symfony components are decoupled well, but some are mutually dependent even if you don't need them:
And we're forced to use Kernel with Http.
Btw, both articles by Matthias Noback and Paul M. Jones are not just critics of tight coupling, but great specific tips about how to create a future proof and solid architecture design. If you haven't seen them, make sure you grasp the main ideas, and they will serve you in the future.
The command-line application run in a command line. There is no HTTP request, no browser, no routes, no session, no-cache.
Instead of URL with parameters, we run command-line arguments and options:
composer require symfony/http-kernel --dev
If there few more "http" classes we never use, we can live with that.
But what do we get when we actually run the composer command above?
There are some packages related to http that's expected. But why is there debugging package in our production tool?
So when we require symfony/http-kernel
, we also get 4 more packages we don't use:
symfony/error-handler
- for debug?symfony/event-dispatcher
- in case we might use events some day?symfony/http-foundation
- over 40 classes related exclusively to http request and responsesymfony/var-dumper
- for debug?Maybe we could say:
That's "optional dependency" in case of
"dev" environment to report bugs.
That's completely ok for request and response http workflow, but one more package we'll never use, but have to maintain.
Now we understand why most CLI app developers decided not to use any container but create their services manually.
You might think:
What do you mean by 'maintain'? It's few dependencies
that we'll never use and just take little space on a disk.
What's the big deal?
Let's get back to the start. In April 2021, we started to develop Rector on PHP 8 and release PHP 7.1 downgraded version. Downgraded and scoped version means fully downgraded and scoped /vendor
. Yes, including all Symfony components we use.
The downgrade PHP market is still quite a niche, but the community is interested more and more in this field. In December 2021 alone, there have been over 20 brand new downgrade rules contributed from Rector users.
When we started downgrading Rector, we often came to incompatible types in FileLoader
classes. The covariant parameters added in PHP 7.4 started to cause problems on PHP 7.3 and bellow.
The most problematic was a downgrade of import()
methods.
In config FileLoader.php
they require bool
parameter:
public function import($resource, $type = null, bool $ignoreErrors = false) {}
But in a dependency injection FileLoader.php
that inherits former class, it is string|bool
:
public function import($resource, $type = null, bool|string $ignoreErrors = false) {}
We worked on a downgrade fix for 2 months before figuring our rules were correct. The problem is in contracts of Symfony method, which is invalid on PHP 7.3 and below.
The problem is not about invalid contracts, and anyone could make a mistake in those. The problem is in maintaining code we don't need nor use.
I wish there were a more straightforward container factory service that we could add to the project:
symfony/dependency-container-factory
Installing...
* symfony/dependency-container-factory
* symfony/dependency-injection
That would allow us only to create the container. With no extra dependencies, with a focus on service tree construction:
use Symfony\Component\DependencyInjection\ContainerFactory;
use Symfony\Component\Console\Application;
$compilerPasses = [...];
$extensions = [...];
$containerFactory = new ContainerFactory($compilerPasses, $extensions);
$container = $containerFactory->createFromConfigs([__DIR__ . '/config/config.php']);
$application = $container->get(Application::class);
✅
It would make CLI apps much cleaner and easier to use Symfony in them.
What do you think? What is your approach to creating service in your CLI applications?
Happy coding!
Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!