Rapid development with autowiring and autoconfiguration

by Mario Blažek -

From the early days of Symfony 2, it’s authors pushed forward to do everything in an explicit and expressive way. Services needed to be explicitly defined in one of the available configuration formats, routing also detailed, and there was no automagical stuff. If we compare it with Laravel at that time, we could say that Laravel was a more rapid application development tool than Symfony. The developer was choosing between Laravel’s RAD features and Symfony’s expressiveness and explicitness. Now, when we move forward to the Symfony 3.3 release, there were some exciting changes introduced.

The new dawn

Symfony 3.3 was released in May 2017, introducing some exciting buzzwords like autowiring and autoconfiguration. The idea is to properly type-hint the arguments of your services and then let Symfony determine which services to pass into your own as constructor arguments. This new way of working with services would drop the step of manually writing the service definitions in the YAML files, for example, allowing the developer to focus more on writing the business instead. That’s a win. This was a brief overview of autowiring. If we are working on some Twig logic and implementing the custom Twig extension, for example, then autowiring alone is not enough. Autowiring itself would create the service definition and inject the required dependencies for us, but we would be missing the twig.extension dependency injection tag. This is where the autoconfiguration jumps in. Every Twig extension implements the Twig\Extension\ExtensionInterface interface, which tells the autoconfiguration system, by some already predefined configuration in TwigBundle, to tag the service definition with the twig.extension tag. Again, the developer can focus more on business logic, than on some internals of Symfony. 

Some might say that these new features introduced in Symfony 3.3 are magic and are introducing the significant layer of dust between the internals of Symfony and the developer itself. The spell itself is not there as the whole process of autowiring and configuration is already pre-configured and works predictably. Mind you, the knowledge of explicitly defining the services is still required.

eZ Platform 3 based Media Site as a playground

In the previous chapter, we had a brief overview of autowiring and autoconfiguration, now let’s build something in a RAD fashion. We are going to use our freshly made eZ Platform 3 Media Site as a playground. It is highly recommended that you follow along as we go through. The code for the starting point can be found on this repository.

A custom view controller

First, let’s create the custom view controller for the ng_recipe content type in the old fashion, as we used to. A simple FullViewController with a displayRecipe method that just returns the view. Nothing fancy, but stay with me, here is the controller code:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Converter\CaloriesConverterInterface;
use Netgen\Bundle\EzPlatformSiteApiBundle\Controller\Controller;
use Netgen\Bundle\EzPlatformSiteApiBundle\View\ContentView;

class FullViewController extends Controller
{
    public function displayRecipe(ContentView $view)
    {
        return $view;
    }
}

The second step is view configuration; we need to tell the eZ, which controller is going to be used for our view. This is done in config/app/packages/content_view.yaml file:

ng_recipe:
    template: "@ezdesign/content/full/ng_recipe.html.twig"
    controller: App\Controller\FullViewController::displayRecipe
    match:
        Identifier\ContentType: ng_recipe 

If you try to open the full view of our recipe in the browser /recipes/sweet-potato-and-black-bean-veggie-burgers, everything works. How? - You might ask. It’s magic. Well, it’s not. The behaviour is explicitly defined in the config/services.yaml file. The _defaults key sets the default configuration options that all other services will inherit, which means that for our FullViewController, the autowiring and autoconfiguration directives are going to be enabled by default. Next, the App\Controller key, defines that everything inside the src/Controller directory should be autowired under App\Controller namespace, plus it should be tagged with controller.service_arguments tag. We will explain the importance of that tag in the next chapters.

If we forget about autowiring for a second, this is what we would need to add to the service.yaml previously:

app.controller.full:
    class: App\Controller\FullViewController
    parent: netgen.ezplatform_site.controller.base 

Not very complicated, but who’s going to remember the identifier of a parent service.

Constructor injection and autowiring

To spice things up a bit, I want to create a service that is going to be responsible for converting the calories into the watt-hours and joules. This is going to be a standalone service that will be injected into the FullViewController as a dependency. First, let’s define a simple interface for our converter:

<?php

declare(strict_types=1);

namespace App\Converter;

interface CaloriesConverterInterface
{
    public function toWattHours(int $calories): float;

    public function toJoules(int $calories): float;
}

And here is the simple implementation of that interface.

<?php

declare(strict_types=1);

namespace App\Converter;

class BaseUnitsConverter implements CaloriesConverterInterface
{
    protected const WATT_HOUR = '0.00116222';

    protected const JOULE = '4.184';

    public function toWattHours(int $calories): float
    {
        return $calories * self::WATT_HOUR;
    }

    public function toJoules(int $calories): float
    {
        return $calories * self::JOULE;
    }
}

Now, we need to pass that service into our controller. The first thing that pops up into my mind is constructor injection, so let’s do that. We will add a $baseConverterConverter property, and a constructor. In the constructor, we put the CaloriesConverterInferface as a type-hint. From one side, we are performing a proper dependency inversion by coding to an interface, and from the other, we are helping Symfony to resolve dependency for us correctly.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Converter\CaloriesConverterInterface;
use Netgen\Bundle\EzPlatformSiteApiBundle\Controller\Controller;
use Netgen\Bundle\EzPlatformSiteApiBundle\View\ContentView;

class FullViewController extends Controller
{
    /**
    * @var \App\Converter\CaloriesConverterInterface
    */
    private $baseCaloriesConverter;

    public function __construct(CaloriesConverterInterface $baseCaloriesConverter)
    {
        $this->baseCaloriesConverter = $baseCaloriesConverter;
    }

    public function displayRecipe(ContentView $view)
    {
        return $view;
    }
} 

If you refresh the full view of our recipe, everything will work. The dependency is going to be autowired and injected in our controller through constructor injection. Again, you might ask, how is that possible? This would mean that our converter also became a service. Suppose you recall the _defaults key from the previous chapter, which enables the autowiring and autoconfiguration for all services defined in that file, there is another directive App\, which specifies that everything inside src directory, except namespaces defined by except key, should be registered as a service. This explains how our converter became a good Symfony service.

Controller methods on steroids

In the first chapter, we mentioned the controller.service_arguments tag. Now let’s explore the feature provided by this tag. From the early days of Symfony 2, most controllers had the whole container injected inside, and every dependency was pulled from the container with the help of $this->get(‘my_service’) statement. Everything available in the container could be pulled out. This is not ideal if you want to minimise the knowledge of the controller. So this controller.service_arguments tag enables us to autowire the controller action method arguments, the same as we can do with a constructor. Let’s change the displayRecipe method to accept the instance of CaloriesConverterInterface:

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Converter\CaloriesConverterInterface;
use Netgen\Bundle\EzPlatformSiteApiBundle\Controller\Controller;
use Netgen\Bundle\EzPlatformSiteApiBundle\View\ContentView;

class FullViewController extends Controller
{
    public function displayRecipe(ContentView $view, CaloriesConverterInterface $baseCaloriesConverter)
    {
        dump(get_class($baseCaloriesConverter));
        return $view;
    }
} 

Very neat, isn’t it. We can do the same with ContentService, for example. All the other repository services are autowireable by default, too. But what if we try to autowire the TranslationHelper service? It will result in an error because TranslationHelper was not ready to be autowired. It’s service definition with the identifier ezpublish.translation_helper still exists, so is already in the service container. We just need to add a simple alias for that service, like this:

services:
    eZ\Publish\Core\Helper\TranslationHelper: '@ezpublish.translation_helper' 

At this point, if you refresh the page, everything works as expected. Do we have to create aliases for every other service to make it autowireable? The answer is no. If you execute the debug:autowiring command, you will see a big list of services that are ready to be autowired in your code. Just type-hint a dependency, and that’s it.

Let’s take a look at another example. Let’s say that we want to log something whenever the displayRecipe is called. To do so, we need a logger service. So let us see what debug:autowiring has to offer. By executing that command, we will know that we have many loggers at our disposal:

Describes a logger instance.
Psr\Log\LoggerInterface (monolog.logger)
Psr\Log\LoggerInterface $buzzLogger (monolog.logger.buzz)
Psr\Log\LoggerInterface $cacheLogger (monolog.logger.cache)
Psr\Log\LoggerInterface $consoleLogger (monolog.logger.console)
Psr\Log\LoggerInterface $debugLogger (monolog.logger.debug)
Psr\Log\LoggerInterface $doctrineLogger (monolog.logger.doctrine)
Psr\Log\LoggerInterface $eventLogger (monolog.logger.event)
Psr\Log\LoggerInterface $httpClientLogger (monolog.logger.http_client)
Psr\Log\LoggerInterface $lockLogger (monolog.logger.lock)
Psr\Log\LoggerInterface $phpLogger (monolog.logger.php)
Psr\Log\LoggerInterface $profilerLogger (monolog.logger.profiler)
Psr\Log\LoggerInterface $requestLogger (monolog.logger.request)
Psr\Log\LoggerInterface $routerLogger (monolog.logger.router)
Psr\Log\LoggerInterface $securityLogger (monolog.logger.security)
Psr\Log\LoggerInterface $translationLogger (monolog.logger.translation) 

Just by type-hinting the Psr\Logger\LoggerInterface in the controller action, we will receive the default logger implementation. But what if I want to use the debug logger for example? Easy. Type-hint the LoggerInterface, plus name the argument variable $debugLogger. That's it.

What when autowiring can’t decide?

Let’s go further a little bit. We received a request from our client to create the converter that is going to perform the conversion of calories to kilowatt-hours and kilojoules. Again, we will use our CaloriesConverterInterface:

<?php

declare(strict_types=1);

namespace App\Converter;

class KiloUnitsConverter implements CaloriesConverterInterface
{
    protected const WATT_HOUR = '0.00000116222';

    protected const JOULE = '0.004184';

    public function toWattHours(int $calories): float
    {
        return $calories * self::WATT_HOUR;
    }

    public function toJoules(int $calories): float
    {
        return $calories * self::JOULE;
    }
}

If we open the browser now on our recipe full view, we will be welcomed by a nasty exception.

Screenshot 2020-09-03 at 16.27.50

Symfony is not able to autowire the CaloriesConverterInterface, because it can’t decide which implementation should be autowired as CaloriesConverInterface. Now we have two implementations of the same interface. There are two ways to solve this problem. First, we can use the bind option under _defaults key in our services.yaml. With bind, we can bind the argument variable to a specific implementation of our interface, like this:

services:
    _defaults:
        bind:
            $baseCaloriesConverter: '@App\Converter\BaseUnitsConverter'
        autowire: true # Automatically injects dependencies in your services.
        autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.

So now, CaloriesConverterInterface $baseCaloriesConverter argument will represent the BaseUnitsConverter service. On the other hand, anything else will represent the KiloUnitsConverter service. That is one way of dealing with this kind of issue. The second option is to create a service alias, the same thing as we did for the TranslationHelper:

services:
    App\Converter\CaloriesConverterInterface: '@App\Converter\BaseUnitsConverter' 

Every time we type-hint the CaloriesConverterInterface, we will receive the BaseUnitsConverter implementation. To get the KiloUnitConverter, we need to explicitly type-hint the KiloUnitsConverter implementation. 

Autoconfigured Twig extension

Most of the time, we dealt with the autowiring feature. Let’s take a look at autoconfiguration. Imagine we want to create a simple Twig filter to perform the conversion directly in the Twig templates. Let’s create an App\Twig\AppExtension class that extends the Twig\Extension\AbstractExtension abstract class:

<?php

declare(strict_types=1);

namespace App\Twig;

use App\Converter\CaloriesConverterInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

class AppExtension extends AbstractExtension
{
    /**
    * @var \App\Converter\CaloriesConverterInterface
    */
    private $baseCaloriesConverter;

    public function __construct(CaloriesConverterInterface $baseCaloriesConverter)
    {
        $this->baseCaloriesConverter = $baseCaloriesConverter;
    }

    public function getFilters()
    {
        return [
            new TwigFilter('base_convert_joules', [$this, 'convertToJoules']),
            new TwigFilter('base_convert_watts', [$this, 'convertToWatts']),
        ];
    }

    public function convertToJoules(string $calories): float
    {
        return $this->baseCaloriesConverter->toJoules($calories);
    }

    public function convertToWatts(string $calories): float
    {
        return $this->baseCaloriesConverter->toWattHours($calories);
    }
}

If we execute the debug:container command with the full namespaced name of our Twig extension, some exciting information will pop up. We can see that our service has the autowired and autoconfigured options set to true. But we already know that from the primary services.yaml file. The exciting thing is the tags option, as our service is automatically tagged with the twig.extension tag. Something that we already mentioned in the first chapter. 

php bin/console debug:container App\Twig\TwigExtension

That’s fine, but how does Symfony know to automatically tag every class that implements the Twig\Extension\ExtensionInterface with the proper tag? The answer lies in the TwigBundle. Inside Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension to be exact:

$container->registerForAutoconfiguration(ExtensionInterface::class)->addTag('twig.extension');

This line registers the ExtensionInterface for autoconfiguration with the twig.extension tag.

And finally, let’s update the templates\themes\app\content\full\ng_recipe.html.twig full view template with our newly created Twig filters:

{# put this in div with class "full-recipe-info" #}
{% if not content.fields.serving_calories.empty %}
    <div class="recipe-calories">
        {{ ng_render_field(content.fields.serving_calories) }} {{ 'ngsite.layout.recipe.cal'|trans }}
    </div>
    <div class="recipe-calories">
        {{ content.fields.serving_calories.value.value|base_convert_joules }} J
    </div>
    <div class="recipe-calories">#}
        {{ content.fields.serving_calories.value.value|base_convert_watts }} Wh
    </div>#}
{% endif %} 

Zero time configuration Symfony command

Lastly, let’s see how autoconfiguration works in the context of Symfony commands. For this purpose, I will create a simple Symfony command that only prints out which implementation of CaloriesConverterInterface is used. I will make an App\Command\ExampleCommand class that extends the Symfony\Component\Console\Command\Command class. Our command can be located anywhere in the src directory. It is not mandatory to be in the Command directory. And our simple Symfony command is here:

<?php

declare(strict_types=1);

namespace App\Command;

use App\Converter\CaloriesConverterInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function get_class;
use function sprintf;

class ExampleCommand extends Command
{
    protected static $defaultName = 'app:example';

    /**
    * @var \App\Converter\CaloriesConverterInterface
    */
    private $baseCaloriesConverter;

    public function __construct(CaloriesConverterInterface $baseCaloriesConverter)
    {
        $this->baseCaloriesConverter = $baseCaloriesConverter;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln(sprintf('Converter implementation used: %s', get_class($this->baseCaloriesConverter)));

        return Command::SUCCESS;
    }
} 

Again, suppose we inspect the service definition inside the container with debug:container command, we will see that everything is set up correctly. Our service is tagged with the console. command tag. This time, the FrameworkBundle takes the responsibility of registering the autoconfiguration. Symfony\Bundle\FrameworkBundle\DependencyInjection\FrameworkExtension to be exact:

$container->registerForAutoconfiguration(Command::class)
->addTag('console.command');

To test if everything is ok, run the example command:

php bin/console app:example
Converter implementation used: App\Converter\BaseUnitsConverter

Conclusion

Autowiring and autoconfiguration features are already available with Symfony for some time. With the release of the eZ Platform 3 that is based on the Symfony 5, most of us got into the autowiring and autoconfiguration in everyday use. Hopefully, I have managed to present those features in the simple and easy way to follow. I hope that autowiring and autoconfiguration features are now demystified for you and that we can all agree that Symfony 5 is going to be a significant productivity boost. 

Truth be told, the autowiring and autoconfiguration features are not for everybody, it is highly recommended to avoid using them in public and reusable bundles like it is stated in the official documentation.

Comments

This site uses cookies. Some of these cookies are essential, while others help us improve your experience by providing insights into how the site is being used.

For more detailed information on the cookies we use, please check our Privacy Policy.

  • Necessary cookies enable core functionality. The website cannot function properly without these cookies, and can only be disabled by changing your browser preferences.