Diving into Symfony’s DependencyInjection — Part 3: Advanced uses
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.
Logging with monolog
Autoconfiguration through interfaces
I like to log what happens in my application to ease my debugging process. That’s why I want to add a logger in my voters. We are going to use the Monolog library.
composer require monolog/monolog
Since we require monolog alone, there is no logger service yet. We have to register it manually. Let’s create a config/monolog.php
file, and load it after the config.php
According to monolog documentation, the Logger needs a channel, which is a string, and a handler. We are going to use a StreamHandler to log messages in the var/app.log
file.
Don’t forget to load it in public/index.php
$loader->load('config.php');
$loader->load('monolog.php');
We could inject it with autowiring in our voters (through a LoggerInterface
hinted constructor), but we are going to make something way better. We'll use the Psr\Log\LoggerAwareInterface
: each class that implements this interface will see the logger injected with a setLogger
method. To do this, we can use the ContainerBuilder::registerForAutoconfiguration()
method
So let’s modify the config/monolog.php
file:
Simply implement the LoggerAwareInterface
in the PostVoter class. You can use the LoggerAwareTrait
to do the implementation for you.
// ...
class PostVoter implements VoterInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
// ...
}
And that’s it, each class that will implement this interface will see the logger injected through the setLogger
method. Let's try it :
public function vote(string $attribute, $subject, User $user): bool
{
$this->logger->debug(self::class . ' executed.');
// ...
}
A var/app.log
file is created :
[2018-06-27 08:29:31] voter.DEBUG: App\Authorization\Voter\PostVoter executed. [] []
[2018-06-27 08:29:31] voter.DEBUG: App\Authorization\Voter\PostVoter executed. [] []
[2018-06-27 08:29:31] voter.DEBUG: App\Authorization\Voter\PostVoter executed. [] []
[2018-06-27 08:29:31] voter.DEBUG: App\Authorization\Voter\PostVoter executed. [] []
There are 4 lines, because in the PostController, we call the AccessManager::decide()
function 4 times.
Handling parameters
During the logger configuration, we use some scalar values that can be used as application parameter, such as :
- the logger channel for the voters
- the path to the log file
The DI component allows us to set parameters, and use/inject them in our app. Let’s improve our configuration: we will create a config/parameters.php
file that is loaded before all configuration files. It will handle all our scalar parameters.
Dont forget to load this new file
We declare 3 parameters:
root_dir
, which is a helper to access the root dir of our applicationapp.logger.voter_channel
, which is the logger channel used by our votersapp.logger.file_path
, the path where we are going to save our logs.
We can see the%foo%
notation. It is a placeholder for a parameter. Here, we’re telling the container "Use theroot_dir
parameter here".
Let’s dump the container before and after the compilation to understand what happens:
// Before compilation
#parameters: array:3 [
"root_dir" => "/Users/alex.m/www/di/config/.."
"app.logger.voter_channel" => "voter"
"app.logger.file_path" => "%root_dir%/var/app.log"
]
#resolved: false// After compilation
#parameters: array:3 [
"root_dir" => "/Users/alex.m/www/di/config/.."
"app.logger.voter_channel" => "voter"
"app.logger.file_path" => "/Users/alex.m/www/di/config/../var/app.log"
]
#resolved: true
So there must be a CompilerPass that resolves our parameters. It is the ResolveParameterPlaceHoldersPass
.
Now, let’s use those freshly created parameters in the monolog.php
config file!
The result is the same, but we now have a well designed configuration:
- application parameters are stored in
parameters.php
- application configuration is handled in
config.php
- third party libraries configuration is handled in their own file
Service decoration
We’ve worked hard to achieve this result 💪🏼. Let’s have fun and decorate our logger with a FancyLoggerDecorator
. This decorator will add some fancy emojis depending on the log level.
⚠️ This decorator is for educational purpose only. Please, do not reproduce at work.
The FancyLoggerDecorator will take as argument the decorated logger and implement the LoggerInterface
. For each method, we'll prepend an emoji to the message to log.
Now, we need to register the FancyLoggerDecorator
as a decorator for the LoggerInterface
, and inject the original (decorated) logger into the decorator. The original id is automatically changed to decorator id + '.inner'
by the DecoratorServicePass
Let’s try our new fancy logger:
$this->logger->debug('debug');
$this->logger->info('info');
$this->logger->notice('notice');
$this->logger->warning('warning');
$this->logger->error('error');
$this->logger->critical('critical');
$this->logger->alert('alert');
$this->logger->emergency('emergency');
And here is our output:
[2018-06-27 09:22:17] voter.DEBUG: 🤖 debug [] []
[2018-06-27 09:22:17] voter.INFO: ℹ️ info [] []
[2018-06-27 09:22:17] voter.NOTICE: 📝 notice [] []
[2018-06-27 09:22:17] voter.WARNING: ⚠️ warning [] []
[2018-06-27 09:22:17] voter.ERROR: ❌ error [] []
[2018-06-27 09:22:17] voter.CRITICAL: 🛑 critical [] []
[2018-06-27 09:22:17] voter.ALERT: 🚨 alert [] []
[2018-06-27 09:22:17] voter.EMERGENCY: 🆘 emergency [] []
Woah such a fancy logger!
Performance improvements
Caching the compiled configuration
For now, the container is built on each request. Because we handle the configuration with php files, and because we have a light configuration, it will not really impact performance. If you are using YAML or XML configuration (or even php), it is still highly recommended to cache your container.
On the first request, the container will be built and compiled, then we’ll dump its content in a file with the Symfony\Component\DependencyInjection\Dumper\PhpDumper
.
Now, we’ll check if the file var/cache/CachedContainer.php
exists. If it is the case we simply require it, if not we rebuild the container:
Try it yourself, your first request will take at least 3 seconds, the next one will be lightning fast!
ℹ️ The PhpDumper will create a
CachedContainer
class that extends Symfony'sContainer
class. It's interesting to have a look at this class, you'll find your resolved parameters in thegetDefaultParameters
function or a function for your public services (getPostControllerService()
in our case) :
ℹ️ You can see that it will create a PostVoter, and inject the fancy logger in it through the
setLogger
method. Then it will create the AccessManager and add the voter to it. Finally, it will create the PostController, and because it is public, it will add it in theservices
array, with its FQCN as id.
DX improvement
The cached container is pretty cool. But if you try to add or remove a definition during your development, it will not be registered unless you clear your cache.
So we are going to create an env
variable that can take dev
or prod
as a value. If this variable is equal to dev, we'll rebuild the container.
// public/index.php
$env = 'dev';$cachedContainerFile = __DIR__ .'/../var/cache/CachedContainer.php';if ('prod' === $env && file_exists($cachedContainerFile)) {
// require the cached container
} else {
// build the container
}
💡 The best practice would be to create an environment variable and read it with the getenv() function, or the The Dotenv Component.
Autowiring arguments by name
Now that we have an environment feature, let’s add a parameter in the container called env
to know in which environment our application is running.
// public/index.php
// ...
$container = new ContainerBuilder();
$container->addCompilerPass(new VoterPass());
// setting `env` parameter before config files are loaded.
$container->setParameter('env', $env);
Now, imagine you want to log the environment in the log context from your fancy logger. You want to inject this parameter in the fancy logger. Since it is a string, we can not autowire it. But Symfony’s DI allows us to autowire parameters through their variable name.
Back to the config/config.php
. Remember the privateDefinition
prototype that will be used for each autoconfigured definition? We'll add a binding to it, so the variable name $env
will be resolved as the env
parameter:
Now, simply add a $env
variable in your PostVoter class and it will be autowired:
Here’s the log output in development and production environments:
# dev
[2018-06-29 16:35:17] voter.DEBUG: 🤖 App\Authorization\Voter\PostVoter voter executed {"env":"dev"} []# prod
[2018-06-29 16:36:44] voter.DEBUG: 🤖 App\Authorization\Voter\PostVoter voter executed {"env":"prod"} []
ℹ️ Note that you must clear your cached container to make it work for prod env. Simply delete the
var/cache/CachedContainer.php
file to generate a fresh one.
Last words
Dependency Injection is a simple yet useful pattern used in any programming language that helps you decouple your application code. This is also perfect to write stronger unit tests, as dependencies become easier to mock.
Symfony’s component is going further by using a powerful container that helps us managing our dependencies in many different ways. It is also shipped with an extension system, which allows third party bundles to share definitions with your container.
📖 If you want to learn more about this component capabilities, I highly recommend you to read the documentation pages available in Symfony’s doc:
https://symfony.com/doc/current/components/dependency_injection.html#learn-more