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:
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.
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:
We wrote a paths()
method that validates provided directory exists. That way, we know about typos or incorrect nesting right in the first second.
As for the rule()
method, we can validate the class exists and that its type is RectorInterface
. Such validation can prevent passing non-existing classes that Symfony silently skips.
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.
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.
When we run the project with the config above, it will crash with the following message:
Could not resolve argument "CryptoWatchConfig $cryptoWatchConfig"
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.
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);
👍️
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-source packages like Rector every day?
Consider supporting it on GitHub Sponsors.
I'd really appreciate it!