How to Make Configs like RectorConfig or ECSConfig for your Symfony Project

We've introduced a new way to configure PHP tools on Symfony DI. A way that is decoupled brings new methods with autocomplete and validates your input. I talk about RectorConfig and ECSConfig.

At first, I thought it was not possible. Symfony is very strict about this and does not allow any extensions. After a few days of hacking Symfony, I found a space to squash <x>Config class. After meeting with Sebastian Schreiber last week, we found an even better generic solution.

Are you interested in a better developer experience for your Symfony project? Keep reading.

This is ECSConfig in action:


What is Good For?

1. Isolate from Framework

This is very important for projects like Rector or ECS, as you'll use them to analyze or refactor code that uses Symfony.


You can have 2 classes with the same Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator name but different return types or event methods:

The autoload will get confused, and your project will crash. Config class in your namespace prevents this.

2. Add own Config Methods with Instant Validation

This is a significant side effect from a developer experience point of view. We can still add parameters and services with the bare ContainerConfigurator class. But what about domain specifics? E.g., In Rector, we want to register rules and run them on specific paths:

use Rector\Config\RectorConfig;
use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__.'/src',
    ]);

    $rectorConfig->rule(ClosureToArrowFunctionRector::class);
};

Here we have 2 domain-specific methods tailored to our developer needs:

3. Narrow Context to Narrow Focus

If we explore all Symfony config features like autowire(), parameters(), or autodiscovery, we'll slowly drift away. When we're driving a car, we should not try to check what cylinder has gasoline and which is exploding. Too much complexity will make our driving distracted and make it easier to crash.

We should provide focus by design. Does your tool generate a static blog? Add sourcePath() and outputPath() methods. Do you work with real-time stats of cryptocurrencies? Create watchCurrencies([...]) method to list currencies to watch, etc.

With the exact 3 methods of simple scalar arguments, your users know what to do.


How to teach Symfony to work with our Custom Config?

Let's try to implement such a config class from scratch, e.g., for a cryptocurrency watcher. We use the ContainerConfigurator as a base-building stone:

namespace CryptoWatch\Config;

use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator

final class CryptoWatchConfig extends ContainerConfigurator
{
    /**
     * @param string[] $currencies
     */
    public function watchCurrencies(array $currencies): void
    {
        // you can validate $currencies to contain only currencies you handle

        // set parameter (or create service), depending on your domain
        $parameters = $this->parameters();
        $parameters->set('currencies', $currencies);
    }
}

The config uses public API methods from ContainerConfigurator to work with parameters and services. But the user of our package will be saved from those implementation details.

They will simply use it like this:

// config/config.php
namespace CryptoWatch\Config\CryptoWatchConfig;

return function (CryptoWatchConfig $cryptoWatchConfig): void {
    $cryptoWatchConfig->watchCurrencies(['btc', 'eth']);
};

That's it! How simple implementation, right?

Here we expect the Symfony to take the CryptoWatchConfig type from the closure, create an instance, and pass it to the dependency injection container. That way, our project will have parameters of key currencies with value ['btc', 'eth']. That's at least how config builders like SecurityConfig in Symfony 5.3 should work.


That's how we wish the Symfony to work, but the reality is slightly... unexpected.


How to Teach Symfony to Accept Custom Configs

When we run the project with the config above, it will crash with the following message:

Could not resolve argument "CryptoWatchConfig $cryptoWatchConfig"

1. New Instance

But why? The config builders only work for extensions, and methods are automatically generated in a temp directory. They're not "use your own config class" friendly.


The PhpFileLoader class is responsible for creating configs. When we explore it, there is a line responsible for creating ContainerConfigurator:

$this->executeCallback($callback, new ContainerConfigurator(...), $path);

It only creates exactly ContainerConfigurator. Nothing else.


What can we Improve Here?

We need to create the same type, as we put in the closure param type. This closure:

return function (CryptoWatchConfig $cryptoWatchConfig): void {
    // ...
}

Should result into:

$containerBuilder = new CryptoWatchConfig(...);

In other words, we need to change PhpFileLoader code into generic solution that takes param type and creates a class from it:

$reflectionFunction = new \ReflectionFunction($callback);
$firstParameterReflection = $reflectionFunction->getParameters()[0];
$containerConfiguratorClass = $firstParameterReflection->getType()->getName();

// the $containerConfiguratorClass is `CryptoWatchConfig` or any config we provide

$this->executeCallback($callback, new $containerConfiguratorClass(...), $path);

👍️

2. Open Param Type

But there is 2nd problem. The type is resolved from param reflection here:

switch ($type) {
    case ContainerConfigurator::class:
        $arguments[] = $containerConfigurator;
        break;

    // ...
    default:
        throw new \InvalidArgumentException(sprintf(
            'Could not resolve argument "%s"', $type
        ));

What can be improved here? Switch for type detection is generally a terrible idea, and it only matches a single exact type:

// exclusive 1 entrance :(
get_class($class) === Controller::class

// children on-board :)
$class instanceof Controller

In other words, we change the code to this:

if ($type instanceof ContainerConfigurator) {
    $arguments[] = $containerConfigurator;
} else {
    // ... fallback to switch
}

👍️

That's it!


We had to change that to make ECSConfig, RectorConfig, MBConfig EasyCIConfig, and the rest work with Symfony. It's a single change to allow all of them, including your custom config that extends ContainerConfigurator.

Do you think this should be part of Symfony core? Let me know in the comments.


tl;dr; Add the patch file and create your config today.


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!