Diving into Symfony’s DependencyInjection — Part 2: Symbiosis with the Config component


In this article, we’re going to dive into Symfony’s dependency injection component. We will use it from scratch, out of the Symfony framework, with the minimum of external dependencies. You can code along with me while reading this article, each step will be detailed so you can follow this article in the best possible way.

Photo by Steve Harvey on Unsplash

Handling configuration with the Config component

Although the DependencyInjection component is itself sufficient, it’s more powerful when it’s paired with the Config component. It allows us to separate the container configuration in dedicated files, like yaml files in a standard Symfony app.

Let’s use it to draw the best potential of the DI.

composer require symfony/config

With the Config component, we are able to separate the container’s configuration in dedicated files. You can configure your container in various ways (using yaml, xml,...). In this article, we'll use php file configuration for 2 reasons:

  • to limit external dependencies (yaml configuration needs the symfony/yaml component).
  • to have a better understanding of what the container is doing during the configuration (which method is called, …)

The configuration will live in a config/config.php file. To load this file, we are going to use a Loader. For php files, there is a Symfony\Component\DependencyInjection\Loader\PhpFileLoader :

public/index.php

The PhpFileLoader::load() performs an include of our configuration file. The following variables will be accessible from the config/config.php file:

  • $container => ContainerBuilder
  • $loader => PhpFileLoader
  • $resource => config.php
  • $type => null in our case (since we are using PhpFileLoader, the type does not matter).

Now, let’s create the config/config.php file.

config/config.php

Autoconfiguration

Now we have a dedicated config file, thanks to our loader, we can define some rules relative to our directories and namespaces in order to automatically configure services. To do so, we have to create a prototype definition, which will be used as the base definition of a group of classes. This prototype will be used for each class of this group. This is called autoconfiguration.

To auto-configure services in a given directory, we use the Loader::registerClasses() function.

config/config.php

If we dump the container before the compilation, we get the following five definitions:

-definitions: array:5 [
  "service_container" => Definition {}
  "App\Authorization\AccessManager" => Definition {}
  "App\Authorization\Voter\PostVoter" => Definition {}
  "App\Entity\Post" => Definition {}
  "App\Entity\User" => Definition {}
]

All classes in the src directory were automatically registered as services. However, we don't want the Entity directory to be autoconfigured. Let's exclude it by adding a third argument to the registerClasses() function.

// src/Entity directory is now excluded
$loader->registerClasses($definition, 'App\\', '../src/*', '../src/Entity');

Here is our new list of definitions:

-definitions: array:3 [
  "service_container" => Definition {}
  "App\Authorization\AccessManager" => Definition {}
  "App\Authorization\Voter\PostVoter" => Definition {}
]

Remember, now you have to access the AccessManager through its FQCN.

$accessManager = $containerBuilder->get(AccessManager::class);

The compiler pass

Let’s improve our code: imagine I’m telling you that tomorrow I will need two more voters. You’ll have to implement them and manually update your config file with service declarations and configuration. The implementation part is unavoidable (well, that’s your job), but the configuration part can be automatic because our voters implement VoterInterface. To do that, we’ll use service tags.

Service tags

Let’s tag all classes that implement the VoterInterface with an app.voter tag. Then we'll inject all tagged services in the AccessManager. We'll need two methods to make it work:

  • registerForAutoconfiguration(): it allows to configure all classes that implement a certain interface.
  • findTaggedServiceIds(): to fetch all tagged services
config/config.php

If you run your application, it won’t work. This is because tags are really set during the container compilation. However, during the configuration loading, the container is not compiled yet. The $container->findTaggedServiceIds('app.voter') method will return an empty array. We need to find a way to interact during the compilation in order to inject our app.voter tagged services into the AccessManger. We can achieve this by creating our own CompilerPass.

Creating a VoterPass

Let’s start by creating a VoterPass class that implements the Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface. This interface exposes a unique processmethod that will be executed during the container's compilation. In this pass, we’ll inject all tagged services to the AccessManager definition thanks to the TaggedIteratorArgument class.

src/DependencyInjection/Compiler/VoterPass.php

Now, we have to register the VoterPass in the container in order to be executed during the compilation.

// public/index.phpuse App\DependencyInjection\Compiler\VoterPass;// ...$containerBuilder = new ContainerBuilder();
$containerBuilder->addCompilerPass(new VoterPass());

We also need to make our definitions as autoconfigured to make it work. Don’t forget to exclude the DependencyInjection directory from the auto configuration (we don’t need to make the VoterPass a service).

config/config.php

From now on if we want to add a Voter, we just need to make it implement the VoterInterface.

To summarize:

in index.php

  • construction of the ContainerBuilder
  • adding the VoterPass that will be executed during the ContainerBuilder::compile() method
  • loading the config/config.php file
  • compiling the container

in config/config.php

  • autoconfigure classes that live in src/* directory (with some exclusions)
  • adding the app.voter tag to all classes that implement the VoterInterface

in VoterPass

  • injection of all app.voter tagged services in the AccessManager's constructor

The controller

Autowiring

For now, we made AccessManager public to access it from the container, but this is a bad practice. In a real application, it will be used by a Controller through autowiring (services will be injected thanks to typehinted arguments).

Let’s try this by creating a PostController in the App\Controller namespace. Since the Controller directory is not excluded from auto configuration, it will be automatically registered as a service.

Since we don’t have any routing system, we will make the Controller public to access it from the index.php file. We will activate autowiring on our definitions to make the magic happen.

Here is the new configuration:

config/config.php

The PostController:

src/Controller/PostController.php

Now, in public/index.php, we'll call the index action of our controller:

// public/index.php$containerBuilder = new ContainerBuilder();$containerBuilder->addCompilerPass(new VoterPass());$loader = new PhpFileLoader($containerBuilder, new FileLocator(__DIR__.'/../config'));
$loader->load('config.php');$containerBuilder->compile();$containerBuilder->get(PostController::class)->index();

It works without specifying any argument to the PostController's definition. The AccessManager was automatically injected through autowiring, thanks to the typehinted constructor.

Setter injection

For now we only inject services through the constructor. But what if you want to inject it via a method call, like a setter: we’ll refactor the AccessManager to have a public setVoters() method and no constructor:

src/Authorization/AccessManager.php

Now to make it work, we have to change the VoterPass to call the setVoters() method instead of the constructor.

src/DependencyInjection/Compiler/VoterPass.php

It’s a nice improvement, but we want to do better. I recommend to have an addVoter(VoterInterface $voter) method and inject the voters one after the other. It will allow us to add some validation during the addVoter call.

Let’s do it. To iterate over tagged services, you can use the findTaggedServiceIds function:

src/DependencyInjection/Compiler/VoterPass.php

And here is our AccessManager::addVoter() function:

// AccessManager
public function addVoter(VoterInterface $voter)
{
    $this->voters[] = $voter;
}

The reason why this is better than a setVoters() function is that we can check that each voter is implementing the VoterInterface thanks to the typehinted argument. So if you add the app.voter tag to a service that does not implement VoterInterface, it will raise an error during the addVoter() call.


What’s next ?

In this part, we’ve learned how to load configuration files in the container. A great developer experience improvement was possible thanks to autoconfiguration : classes that are in a specific namespace will be automatically configured as services. We’ve also seen how to pass a service to another one with autowiring, by using typehints. Last but not least, we’ve created our first CompilerPass in order to interact with tagged services during the container’s compilation.

Are you still thirsty of knowledge? Do you want to know how to improve the performance of your application by caching your compiled container? Do you have a service from a third party library, and want to add more features to it? Does your app have scalar parameters that you want to autowire? You’ll find answers to these questions (and more!) in the next and last part of this article.

Manomano Tech

Behind the scene: we share stories about our product, our data science & our engineering

Alex Makdessi

Written by

Backend dev @manomano

Manomano Tech

Behind the scene: we share stories about our product, our data science & our engineering