Experiment: How I replaced Symfony DI with Laravel Container in ECS

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 Scope

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.


The Main Difference between Symfony and Laravel Container

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.


Step 1: Let's create a Laravel Container

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.


Step 2: Let's Register Commands

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.


Step 3: Registering a Simple Service

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);

Step 4: A service that requires a collection of other services

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!


Step 5: From Compiler Pass to...?

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.


Step 6: Add extra call or parameters to a Service

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;
});

Step 7: Import Configuration Files

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!


First Results: Developers' Experience and Performance

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-souce packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!