Skip to content

[DI] Add a new "PHP fluent format" for configuring the container #22407

Closed
@mnapoli

Description

@mnapoli
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 or decorates?
  • 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
  • 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 container
  • register: noise too, the goal of the config is to register stuff, no need to state the obvious
  • 'mailer', the service name, is not very visible
  • addArgument, addMethodCall: when configuring a service we don't really want to "add" arguments or method calls, we want to define them, the word add 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 and addMethodCall 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions