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.
This article is divided into three posts:
First steps with the container
In this part you will learn how to setup a container from scratch and add definitions to it. We will create a simple security feature so we can interact with the container.➡ Symbiosis with the Config component
The DependencyInjection works well with the Config component. You’ll learn how to separate configuration in dedicated files to ease configuration and injection with autoconfiguration and autowiring.Advanced uses
In this final part we’ll work with monolog to apply some advanced uses of the DependencyInjection component like service decoration. We’ll also learn how to cache the compiled container to improve our application performances.
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 thesymfony/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
:
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.
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.
ℹ️ The DependencyInjection component will use the FQCN as the service’s id, therefore instead of doing
get('access_manager')
to access the AccessManager, you have to doget(AccessManager::class)
.
To auto-configure services in a given directory, we use the Loader::registerClasses()
function.
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
https://symfony.com/doc/current/service_container/tags.html
Service tags are a way to tell Symfony or other third-party bundles that your service should be registered in some special way.
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
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
https://symfony.com/doc/current/service_container/compiler_passes.html
Compiler passes give you an opportunity to manipulate other service definitions that have been registered with the service container. You can read about how to create them in the components section “Creating Separate Compiler Passes”.
Let’s start by creating a VoterPass class that implements the Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface
. This interface exposes a unique process
method 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.
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).
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 theContainerBuilder::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 theAccessManager
'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).
https://symfony.com/doc/current/service_container/autowiring.html
Autowiring allows you to manage services in the container with minimal configuration. It reads the type-hints on your constructor (or other methods) and automatically passes the correct services to each method.
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:
The PostController:
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:
Now to make it work, we have to change the VoterPass
to call the setVoters()
method instead of the constructor.
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:
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.
📖 You can read the next part here: Part 3: Advanced uses