Description
Q | A |
---|---|
Bug report? | no |
Feature request? | yes |
BC Break report? | no |
RFC? | yes |
Symfony version | 3.3 or 4.0? |
How about adding a new configuration format for the dependency injection container?
TL/DR
Short version: YAML and XML suck in their own ways. We are PHP developers, we know how to write PHP code and we have all the tools we need around PHP.
Let's write configuration in PHP. That doesn't have to be verbose or boring, on the contrary it's possible to improve the developer experience.
A possible solution is detailed at the end. The main idea is to take the best from YAML, XML and PHP and imagine a new and better configuration format.
Intro
The current formats:
- YAML
- XML
- PHP API of the container
To be clear, I'm suggesting adding a 4th format.
3 options is already a lot, adding a 4th is maybe not the best. But I think we can write a new format that is better than these 3 formats altogether. I believe that new format could replace all other 3 eventually.
At first the new format could be tagged as experimental.
Why?
The downsides of the existing formats.
YAML
YAML is easy to read, but:
- error prone to write (indentation issues, arrays in arrays in objects in array, …)
- YAML is just a data structure, adding behavior requires hacking up the format and inventing a custom language on top of it:
@service_name
for service references,%param_name%
for parameters%env(...)%
for environment variables@?app.mailer
for optional injection- requires using the expression language for doing basic logic that could be done in PHP:
"@=service('mailer_configuration').getMailerMethod()"
- etc. YAML config starts to look like Lisp or regexps with all the special characters
- no autocompletion of the YAML API available, e.g. should I type
decorate
ordecorates
? - no documentation of the YAML API in IDEs (e.g. wondering what does
autowiring_types
do? -> there is no phpdoc for that, you have to go find it in the online documentation) - no static analysis of the PHP items (e.g. recognize if you type a class name that doesn't exist, refactoring support, autocompletion, …)
- both last points are partially addressed by IDE plugins like the amazing Symfony plugin for PhpStorm
- the Symfony plugin still comes with issues:
- a huge part of the IDE tooling around Symfony relies on one developer, which is very fragile
- it's written in Java (obviously) which can explain why there are so few contributors yet so many users
- in general all those plugins come with the root issue that they are extremely complicated since they have to support a whole configuration language with a lot of options
- the Symfony plugin still comes with issues:
- there is also no native YAML reader in PHP, forcing to require
symfony/yaml
. This is also why the XML format is encouraged for open source bundles, leading to 2 different formats being recommended.
XML
XML has the benefit of being more strict and benefit from validation/IDE helpers, but:
- verbose and harder to read (which is probably why YAML is preferred in the community)
- hard to type without an IDE
- while there can be autocompletion of XML items (tags, attributes, etc.) thanks to the XSD, there is still no static analysis of PHP items here too (e.g. recognize if you type a class name that doesn't exist, refactoring support, autocompletion, …). The point about IDE plugins made in the YAML section still stands.
PHP
By "PHP" I mean the PHP API of the Container
class, for example as a reminder:
$container = new ContainerBuilder();
$container
->register('mailer', 'Mailer')
->addArgument('sendmail');
This format brings stricter validation and IDE support (autocompletion of the $container
methods for example, but also static analysis of PHP classes mentioned in the config), but:
- the YAML and the XML format are declarative formats (you declare definitions and let the container read them), the PHP API is an imperative format (because the code is executed and manually registers services one after the other)
- this is inconsistent with YAML and XML
- this is not configuration, this is code: configuration is "data" and should declared in a declarative way - the reason is mostly related to DX and is detailed below
- the container's API is optimized for writing code: method names are explicit and follow classic standards (getters, setters, hassers, …) => it's not optimized for declaring configuration, it's too verbose
Take for example this piece of config:
$container
->register('mailer', Mailer::class)
->addArgument('%mailer.transport%')
->addMethodCall('setLogger', [new Reference('logger')]);
These are the tokens we see:
$container
: this word is noise, we are in the config so we know we are configuring the containerregister
: noise too, the goal of the config is to register stuff, no need to state the obvious'mailer'
, the service name, is not very visibleaddArgument
,addMethodCall
: when configuring a service we don't really want to "add" arguments or method calls, we want to define them, the wordadd
is noise.new Reference(...)
: this is actually a good thing: an explicit PHP object for representing a reference, instead of using a string convention like@logger
in YAML. It's a bit verbose though
Here is the equivalent config in YAML:
services:
mailer:
class: Mailer
arguments: ['%mailer.transport%']
calls:
- [setLogger, ['@logger']]
YAML has a lot of problems (no need to list them again), but it gets some things right:
- less "noise" words
- the service name is more visible (as array key)
addArgument
andaddMethodCall
are now declarative sections:arguments
,calls
-> just as explicit but also much shorter and clearer to read
The format
Learning from the pros and cons of all those formats, here is how I would sum it up:
- needs to be in PHP
- we are PHP developers, we know how to write PHP code and we have all the tooling we need that surrounds it
- can embbed its own documentation (through phpdoc)
- more powerful than YAML since you can write an infinite number of helpers (functions, methods, classes, …) whereas YAML is textual data and can only be expanded through weird conventions and use of special characters
- less verbose than it used to be since PHP 5.4 (short arrays), PHP 5.5 (
::class
allowing to use imported class names)
- needs to exploit as much as possible strict typing and static validation to reduce ambiguity or mistakes:
- rely as few as possible on strings (or magic methods): use explicit methods as much as possible (for validation, autocompletion, …)
- encourage using
::class
to reference class names (means refactoring in most IDEs)
- needs to have a declarative syntax as simple and explicit as possible
What I mean by that is that this kind of format, for example, is NOT a good solution:
return [
'mailer' => [
'class' => Mailer::class,
'arguments' => ['@logger'],
],
];
This is a clone of YAML in PHP and it suffers from the same downsides. arguments
could be mistyped, @logger
is still a magical string, etc.
Proof of concept
With the help of others, I've played with a possible solution in https://github.com/mnapoli/fluent-symfony
The main idea behind that is to build definitions through a fluent API, and declare them at the same time in an array.
return [
'mailer' => create(Mailer::class)
->arguments('Hello !'),
];
You will also notice the create()
function, which is kind of unusual. This is a helper function that returns an object. Its role is to avoid this complexity:
return [
'mailer' => (new ServiceDefinition(Mailer::class))
->arguments('Hello !'),
];
create()
is simpler, shorter, and much more explicit. It says an instance of Mailer
will be created for the mailer
service: simple.
Here is another example with get()
, which replaces the magic @
to inject another service:
return [
'mailer' => create(Mailer::class)
->arguments(get('logger')),
];
Functions are usually viewed as "bad" because they are global: those are helper functions, they do not need to conform to OOP best practices. The only problem with those functions is to namespace them correctly to avoid conflicts with other libraries. Thanks to PHP 5.6 it's not an issue as they can be imported like classes:
use function Symfony\DependencyInjection\create;
return [
'mailer' => create(Mailer::class),
];
Illustrations
A picture is worth a thousand words:
-
auto-completion on classes or constants:
-
auto-completion when writing configuration:
-
real time validation in IDEs:
-
constant support:
More examples
I will not list all syntax options, you can read all of them here: https://github.com/mnapoli/fluent-symfony#syntax
Below is a compilation of most common use cases, hopefully they illustrate how nice such a format could be in terms of DX:
return [
// parameters can be defined as raw values in the same array, at the root
'db_host' => 'localhost',
'db_port' => 3306,
// service ID == class name
Mailer::class => create(),
// autowiring
Mailer::class => autowire(),
Mailer::class => create()
->arguments(get('logger')) // inject another service with the get() helper
->method('setHost', 'smtp.google.com'),
// factory
Mailer::class => factory([MailerFactory::class, 'create'])
->arguments('foo', 'bar'),
// aliases
'app.mailer' => alias('app.phpmailer'),
// environment values
'db_password' => env('DB_PASSWORD'),
// tags
Mailer::class => create()
->tag('foo', ['fizz' => 'buzz'])
->tag('bar'),
// import another config file
import('services/mailer.php'),
// define extensions
extension('framework', [
'http_method_override' => true,
'trusted_proxies' => ['192.0.0.1', '10.0.0.0/8'],
]),
];
We could even go further for extensions and define helpers that provide an object-oriented API to configure them. For example with the framework
extension:
return [
framework()
->enableHttpMethodOverride()
->trustedProxies('192.0.0.1', '10.0.0.0/8'),
];
But then comes nested entries which may require more thinking :)
Everything at the root
You may notice that services, parameters and extensions are all mixed up at the root of the array. I did it just because it was possible. The function helpers make it possible to mix everything, and I found it made everything simpler.
It's of course just one of the many possibilities.
How to add a 4th format without really adding a 4th format?
A possibility is to build on top of the existing PHP format. That would avoid having to write new loaders (and this kind of hack to support existing PHP config files).
In the existing format, a configuration file expects a $container
variable to be available, for example:
<?php
$container->register('mailer', 'Mailer');
We could keep that and write a helper that would "apply" an array config to the container:
<?php
use Symfony\DependencyInjection\applyConfig;
applyConfig($container, [
'mailer' => create(Mailer::class),
]);
// Another possibility
$container->load([
'mailer' => create(Mailer::class),
]);
Just an idea for the implementation.
Disclaimer
I would not be honest if I did not mention that most of these suggestions are based on PHP-DI :) In version 3 it supported configuration formats very similar to Symfony (YAML, XML and PHP). Starting from version 4 I dropped all these formats and instead switched to a PHP-oriented format. Since then I'm entirely convinced that that kind of format is much better in terms of developer experience. I would love to see that kind of format land in Symfony.
Conclusion
What do you think?
I want to make clear that all of these are suggestions and that all aspects are up for discussion. In other words, if you disagree with very specific syntax choices, that's fine. I'm mostly interested for now in whether or not such a big change is doable.