PHPnews.io

What if We Remove Strings from Symfony Extension Configuration

Written by Tomáš Votruba / Original link on Aug. 24, 2020

I like the service configuration provided by Symfony. Typo-proof, everything is autocompleted by IDE, hard to put the wrong argument or make a typo.

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

return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();

    $services->defaults()
        ->autowire()
        ->autoconfigure()
        ->public();

    $services->set(SomeService::class)
        ->args(['some', 'value']);
};

This config is a joy to use in IDE. Only the values that can change, like class name or arg value, are strings. Everything else is API of the modeling tool, here ContainerConfigurator from Symfony. This code is a state of Art.


But that's not everything we have in our configs. Let's look at a common extension you can found in config/packages/doctrine.php in your Symfony project:

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

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('break', [
        'dbal' => [
            'host' => '%env(DATABASE_HOST)%',
            'user' => '%env(DATABASE_USER)%',
            'password' => '%env(DATABASE_PASS)%',
        ],
    ]);
};

How do you like this?

I'll share you secret deep from my traumatized mind - this is what I see:

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

return static function (ContainerConfigurator $containerConfigurator): void {
    $containerConfigurator->extension('error', [
        'bug' => [
            'typo' => ' _missing',
            ' problem ' => 'sorry_renamed',
            'forgotten' => 'FALSE POSITIVE',
        ],
    ]);
};
"Anything that can go wrong, will go wrong.
In the worst possible order. When you least expect it."


How can we make Extension as Good as Service Registration?

If we look at ContainerConfigurator, it inherits from abstract class AbstractConfigurator.

What if we use per-extension Configurator... e.g. DoctrineConfigurator?

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->user('%env(DATABASE_USER)%')
        ->password('%env(DATABASE_PASS)%');
};

Slightly less space fo bug... Still, I managed to split one there. It can cause a "connection to database rejected" error.

Environment Variables for Everyone

Have you spotted it?

 return static function (DoctrineConfigurator $doctrineConfigurator): void {
     $doctrineConfigurator->dbal()
         ->user('%env(DATABASE_USER)%')
-        ->password('%env(DATABASE_PASS)%');
+        ->password('%env(DATABASE_PASSWORD)%');
};

Jan Mikes shared with me an interesting idea that removes this problem:

use DoctrineEnvs;

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->user('%env(' . DoctrineEnvs::DATABASE_PASSWORD . ')%')
        ->password('%env(' . DoctrineEnvs::DATABASE_PASSWORD . ')%');
};


That seems like too much clutter... can we make it simpler and safes?

use DoctrineEnvParams;

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->user(DoctrineEnvParams::DATABASE_PASSWORD)
        ->password(DoctrineEnvParams::DATABASE_PASSWORD);
};


In what cases is this the most useful?


Common key for the "database name" is never the same across database platforms and language integration. Imagine the saved hours and lifes on Docker/Doctrine/CI typos:

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->database('%env(DATABASE_NAME)%');
        // or...?
        ->database('%env(DATABASE_DBNAME)%');
        // or...?
        ->database('%env(DATABASE_DATABASE)%');
        // or...?
        ->database('%env(DB_NAME)%');
};

Just don't care and use IDE autocomplete:

use DoctrineEnvParams;

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->database(DoctrineEnvParams::DATABASE_NAME);
};

What are Benefits over Array configuration?

Focus on Config without Jumping Elsewhere

Narrow Context

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->... // dbal specific methods

    $doctrineConfigurator->orm()
        ->... // orm specific methods
};
return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->conntection()
            ->... // only connection specific methods
};

The DoctrineConfigurator with specific methods() and Constant::KEYS is one way to get rid of all string possible.

What about Value Objects?

With PHP 8.0 to be released in 11/2020 will come named arguments. With them, the IDE autocomplete becomes more powerful. If we combine it along with __construct validation in value objects, we have another solid way to add parameters:

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->connection(new DbalConnection(
            DoctrineEnvParams::DATABASE_USER,
            DoctrineEnvParams::DATABASE_PASSWORD,
            DoctrineEnvParams::DATABASE_NAME
        ));
};

Compared to method() autocomplete, we can also see what arguments are required and which optional:

// using PHP 8.0 syntax with constructor promotion

final class DbalConnection
{
    public function __construct(
        private string $user,
        private string $password,
        private string $database,
        private ?string $version = null
    ) {
        // ...
    }
}

Instant Feedback Loop

If we get rid of strings that can go wrong, we've made a big shift to senior codebase.

Another huge benefit for programmers is focus - along with instant feedback loop.

"If something goes wrong, I want to know it the moment it went wrong"


How is instant feedback loop related to value objects and require arguments? Good question!

If we put user and password but forget the database name, the application will crash when it's connected:

use DoctrineEnvParams;

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->user(DoctrineEnvParams::DATABASE_USER)
        ->password(DoctrineEnvParams::DATABASE_PASSWORD);
};

That's very late!

With value objects, we'll get the "missing 3rd argument" error:

return static function (DoctrineConfigurator $doctrineConfigurator): void {
    $doctrineConfigurator->dbal()
        ->connection(new DbalConnection(
            DoctrineEnvParams::DATABASE_USER,
            DoctrineEnvParams::DATABASE_PASSWORD
            // boom :(
        ));
};

That's fast feedback loop!


These were few ideas, how to get rid of strings in /config directory, so we can instead focus on something we love - algorithms, coding, and maybe a coffee cup!


I bet you can't come up with a better way to do this... share in comments to prove me wrong ;)


Happy coding!

tomasvotruba

« Time traveling is coming to Laravel 8 - Writing Reusable Alpine Components »