This year I've been learning Laravel and quickly adapting to most of my tools. I've made 2 packages - Punchcard to handle configs and Bladestan for static analysis of Blade templates using PHPStan.
The component I wanted to put in tests was Laravel Container. Everything went well on small projects, but what about packages with 10 000 000+ downloads?
This week, I gave it a try on ECS, and this is how it went.
I'm not much fan of "best practices", Tweets by authorities, or "this works for everyone" claims. What works for you doesn't have to work for me and vice versa. Instead, I prefer controller experiments where I can see real numbers on a single real project.
The projects I work with are typically CLI PHP applications. They use Symfony DI and Symfony Console. They are not web applications, so I don't need to care about HTTP requests, sessions, or cookies.
Unfortunately, the symfony/dependency-injection is tightly coupled with
symfony/http-kernel` as I already wrote in "What I prefer about Laravel Dependency Injection over Symfony". This and another complexity leads to slow container compilation and unnecessary complexity we have to learn, counteract in case of parameter invalidation, downgrade, and maintain.
Also, CLI tools are stuck with Symfony 6.1 because Symfony 6.2 uses complex PHP features (some reflection + attributes combo, not sure exactly) that Rector fails to downgrade to PHP 7.2 without breaking it.
On the other hand, Laravel container contains only 6 PHP files – only 2 files contain some logic:
It has no external dependencies, except contracts packages:
{
"require": {
"php": "^8.2",
"illuminate/contracts": "^11.0",
"psr/container": "^1.1.1|^2.0.1"
}
}
This seems like a good candidate for a DI container, where all you need is to get a service with injected dependencies, right?
Today we'll focus on practical drop-in replacement of symfony/dependency-injection with
illuminate/container` in Easy Coding Standard, how I've done with the help of Chat GPT and what are the results.
Nothing more, nothing less. If this goes well, I want to try and measure a similar experiment on Rector or legacy projects we upgrade.
One of the often-mentioned differences is that Symfony compiles container, and Laravel creates services on the fly. But that was never a problem or benefit for me.
A more practical difference is that:
But there is a catch - Laravel creates everything from scratch, so if you require a service 2 times, you'll get 2 different instances. To avoid that, you have to explicitly register this service.
I find this very useful because it forces me to write clean stateless services - once a service depends on a state, e.g., I have to set some configuration at a random point of time except the constructor, then it's not a service design and should be refactored.
All clear? Let's deep dive into the experiment. I'll share the pull request link at the end so you can review all the changes yourself.
In Symfony, we create Kernel, where we register service configs and compiler passes. Using container builder, we build a container that we fetch from the container:
$kernel = new Kernel();
$kernel->addCompilerPass(new SomeCompilerPass());
$kernel->addConfig(__DIR__ . '/config/some_config.php');
$kernel->boot();
$container = $kernel->getContainer();
$application = $container->get(Application::class);
$application->run();
In Laravel, we only create Container
and ask for a service:
use Illuminate\Container\Container;
use Symfony\Component\Console\Application;
$container = new Container();
$application = $container->get(Application::class);
$application->run();
That's it!
This is typically part of the bin/ecs
file, where we create a container and fetch a console application to run a console command, e.g., bin/ecs check src
.
In Symfony, we must explicitly register commands in services.php
or with PSR-4 autodiscovery, autowire, and autoconfigure. Then we also depend on Kernel correctly injecting commands:
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$services = $containerConfigurator->services();
$services->defaults()
->autowire()
->autoconfigure();
$services->load('App\\AppBundle\\', __DIR__ . '/../src/AppBundle');
};
In Laravel, I've decided to avoid configs altogether and require commands explicitly via the constructor:
use Symfony\Component\Console\Application;
final class EasyCodingStandardConsoleApplication extends Application
{
public function __construct(
CheckCommand $checkCommand,
WorkerCommand $workerCommand,
) {
parent::__construct('EasyCodingStandard', StaticVersionResolver::PACKAGE_VERSION);
$this->add($checkCommand);
$this->add($workerCommand);
}
}
Thanks to automated service creation, I don't have to worry about registering CheckCommand
and WorkerCommand
in the config. Laravel handles this for me once at the start of the application.
In the previous step, we skipped an important part: registering a simple service.
In Symfony:
$services->set(Filesystem::class);
In Laravel... we don't have to do anything. Laravel container handles it for us.
How about a tagged service? In Symfony:
$services->set(ConsoleFormatter::class)
->tag(FormatterInterface::class);
In Laravel we tag service in a standalone line:
$container->singleton(ConsoleFormatter::class);
$container->tag(ConsoleFormatter::class, FormatterInterface::class);
A typical example is a SniffFileProcessor
or FixerFileProcessor
that collects and runs all sniffers or fixers on a file. Both frameworks use tagged services, so we only collect them and pass them along.
In Symfony, we only set specific arguments with tagged services:
$services->set(FileProcessor::class)
->arg('$sniffs', tagged_iterator(Sniff::class));
In Laravel, there is a similar way:
$container->singleton(FileProcessor::class, FileProcessor::class);
$container->when(FileProcessor::class)
->needs('$sniffs')
->giveTagged(Sniff::class);
});
Update: Thanks Martin for the tip!
So far, we only handled simple steps such as registration of services. Let's level up a bit.
In ECS, sometimes we want to skip a fixer/sniff entirely because it doesn't fit our preference:
use Symplify\EasyCodingStandard\Config\ECSConfig;
return function (ECSConfig $ecsConfig): void {
$ecsConfig->skip([
SomeStrictFixer::class,
]);
};
How do we remove a service from the container? In Symfony, we have compiler passes that run before the container is compiled:
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class RemoveExcludedCheckersCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $containerBuilder): void
{
// resolve excluded class from skip() parameter
$excludedClass = '...';
$containerBuilder->removeDefinition($excludedClass);
}
}
That's it! From now on, the SomeStrictFixer
will not be anywhere in our application. It's like it never existed.
In Laravel, this became quite a challenge. Instead of adding a compiler pass, we run the beforeResolving()
method. This method runs before every service is resolved, so we pick one of those that will get initialized at the start.
Removing service from the container is straightforward. But there is a catch for tagged services - if we don't remove it from tagged services, it will still get injected via $container->tagged()
. Here is the solution I came up with (I'm sure there is a better way):
use Illuminate\Container\Container;
$container->beforeResolving(
FixerFileProcessor::class,
static function ($object, $misc, Container $container): void {
$this->removeServiceFromContainer($container);
}
);
private function removeServiceFromContainer(Container $container): void
{
// resolve excluded class from skip() parameter
$excludedClass = '...';
// remove the instance
$container->offsetUnset($excludedClass);
$tags = PrivatesAccessorHelper::getPropertyValue($container, 'tags');
foreach ($tags as $tag => $classes) {
foreach ($classes as $key => $class) {
if ($class !== $excludedClass) {
continue;
}
// remove the tagged class
unset($tags[$tag][$key]);
}
}
// update value in the container
PrivatesAccessorHelper::setPropertyValue($container, 'tags', $tags);
}
I feared the migration of compiler passes the most, but ChatGPT showed me one more method: afterResolving()
. With these 2 methods replacing compiler passed is easy.
Fun fact: I didn't find these 2 methods in official Laravel documentation.
In Symfony, when we want to add a call or property, we use the call()
or property()
method:
$definition = $services->set($checkerClass);
$definition->call('methodCall', [123]);
$definition->property('$publicProperty', ['hey']);
In Laravel, we can use an extend()
method:
$container->extend($checkerClass, function (CheckerClass $checkerClass) {
$checkerClass->configure(123);
$checkerClass->publicProperty = 'hey';
return $checkerClass;
});
Last but not least, sometimes we need to import external configuration. E.g., in ECS, we want to import a set of rules (services):
use Symplify\EasyCodingStandard\Config\ECSConfig;
use Symplify\EasyCodingStandard\ValueObject\Set\SetList;
return function (ECSConfig $ecsConfig): void {
$ecsConfig->sets([SetList::PSR_12]);
};
In Symfony, we use the import()
method in combination with PHP or YAML file loader:
$containerBuilder->import($filePath);
What actually "importing a new file" does?
Saying that in Laravel, we pass this container to the included file closure:
$closureFilePath = require $filePath;
$closureFilePath($container);
That's it!
What has changed? I enjoy working with DI again. I don't have to include any configs nor configure a directory to load services from.
The services are created for me. Is there some non-standard or weird situation? Define it explicitly in the container.
The speed is fantastic and will improve once we figure out Laravel container bottlenecks. Thanks to GPT and a neat tests suite that reported broken places, I was able to make the switch under 6 hours.
I look for the following ideas once the dust settles.
I didn't expect this, but happy to see that tests run 3-4 times faster with the Laravel container:
I'll release a new ECS version for more performance testing and try it out in the wild. I also want to check how the /vendor
size changed, as that's crucial in CLI tools that include downgraded and scoped /vendor
.
You can review these changes in detail yourself a single pull-request in ECS repository. Tests were passing 100 % before with Symfony DI, and they were passing 100 % after with the new Laravel Container.
I'm a Laravel-beginner, so if you see a better way to achieve some goal, let me know in the pull request. Thank you!
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!