In this post I will look at how to write a more sophisticated Dependency Injection Extension for your Symfony2 bundles.
Bundle Level Config
In my earlier post Symfony2: Controller as Service I looked at creating a very basic extension that would allow us to load an XML file defining the services. The class looked like this:
<?php
namespace LimeThinking\SpringBundle\DependencyInjection;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\Config\FileLocator;
class LimeThinkingSpringExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.xml');
}
public function getAlias()
{
return 'lime_thinking_spring';
}
}
?>
This loads a single service config XML file. You are not tied to using XML for this, YAML or PHP configuration can also be used and would be loaded as follows:
public function load(array $configs, ContainerBuilder $container)
{
$loader = new YamlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.yml');
}
public function load(array $configs, ContainerBuilder $container)
{
$loader = new PHPFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.php');
}
Doing any of the above will load the relevant service config into the Dependency Injection Container (or Service Container as it is also known). Whilst you can use YAML or PHP config files the best practice recommendation is to use XML for bundle service configuration if the bundle is intended to be released as a third party bundle. Whilst YAML is good for simpler config files the verbosity of XML is actually an advantage when maintaining a more complicated one. Likewise the nature of the PHP config means it is more difficult to decipher, the choice is still your's though.
You are not limited to loading a single config file, this way you can split up complicated service definitions into manageable files. For any parameters or service definitions that appear in multiple files those loaded later will overwrite these values. You can also then only load the definitions needed for an app rather then everything. For example in the AsseticBundle only the filters specified in the app config will have their service definition loaded.
For more details on how to write the bundle level XML config please read my posts, Symfony2: Dependency Injection Types and Symfony2: Injecting Dependencies Step by Step
App level config
So far I have only looked at loading config kept within the bundle. You can open up parameters to be set from an app's config file. Any config parameters relating to the bundle from the app's config will be passed into the load method of our extension as the $configs
argument. So if the app's config.yml file contained the following settings:
lime_thinking_spring:
loader_class: LimeThinking\SpringBundle\Asset\Loader\XMLLoader
loader_method: load
filters:
filterOne: LimeThinking\SpringBundle\Filters\FilterOne
filterTwo: LimeThinking\SpringBundle\Filters\FilterTwo
then the passed in $configs
array will be the following array
array(1) {
[0]=>
array(3) {
["loader_class"]=>
string(48) "LimeThinking\SpringBundle\Asset\Loader\XMLLoader"
["loader_method"]=>
string(4) "load"
["filters"]=>
array(2) {
["filterOne"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterOne"
["filterTwo"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterTwo"
}
}
}
It is nested in an array because you can have multiple config files, each will be placed into an array. So if you have also have settings in config_dev.yml:
lime_thinking_spring:
loader_class: LimeThinking\SpringBundle\Asset\Loader\ArrayLoader
loader_method: get
Then the received configs array would like this:
array(2) {
[0]=>
array(3) {
["loader_class"]=>
string(48) "LimeThinking\SpringBundle\Asset\Loader\XMLLoader"
["loader_method"]=>
string(4) "load"
["filters"]=>
array(2) {
["filterOne"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterOne"
["filterTwo"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterTwo"
}
}
[1]=>
array(2) {
["loader_class"]=>
string(50) "LimeThinking\SpringBundle\Asset\Loader\ArrayLoader"
["loader_method"]=>
string(3) "get"
}
}
You can then use these to set parameters in the Container in the following way:
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.xml');
$container->setParameter(
'lime_thinking_spring.formulaloaderclass',
$configs[0]['loader_class']
);
}
Edit: Accessing the config variables in the above example does not deal with merging the values from different configs, it has effectively ignored the setting in config_dev.yml. Using the Config component as described below will avoid this problem.
Configuration Building
It is not however advisable to just set any values in the config to the DIC's service definitions. It would be much better to only set the values you want and to do some validation of the values passed in. Fortunately the pain of this is alleviated by using the built in Config component. We can build a configuration using its fluid interface which allows us to build up complex configurations specifying value types and more . Lets start with an example to show how to do this:
<?php
namespace LimeThinking\SpringBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;
class Configuration implements ConfigurationInterface
{
public function getConfigTreeBuilder()
{
$builder = new TreeBuilder();
$builder->root('lime_thinking_spring')
->children()
->scalarNode('loader_class')
->defaultValue('LimeThinking\SpringBundle\Asset\Loader\ArrayLoader')
->end()
->scalarNode('loader_method')->defaultValue('fetch')->end()
->booleanNode('extra_value')->defaultValue(true)->end()
->arrayNode('filters')
->children()
->scalarNode('filterOne')->end()
->scalarNode('filterTwo')->end()
->end()
->end()
->end()
;
return $builder;
}
}
We can then use this to process the values passed in from the app's configs by adding to our Extension's load method:
<?php
namespace LimeThinking\SpringBundle\DependencyInjection;
use Symfony\Component\Config\Definition\Processor;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
class LimeThinkingSpringExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container)
{
$loader = new XmlFileLoader(
$container,
new FileLocator(__DIR__.'/../Resources/config')
);
$loader->load('services.xml');
$processor = new Processor();
$configuration = new Configuration();
$config = $processor->processConfiguration($configuration, $configs);
$container->setParameter(
'lime_thinking_spring.formulaloaderclass',
$config['loader_class']
);
//--
}
//--
}
?>
The Processor will merge the values from $configs
with the Configurtaion we have built checking the values as we go. In the above case we will end up with a a merged config that looks like this:
array(4) {
["loader_class"]=>
string(50) "LimeThinking\SpringBundle\Asset\Loader\ArrayLoader"
["loader_method"]=>
string(3) "get"
["filters"]=>
array(2) {
["filterOne"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterOne"
["filterTwo"]=>
string(43) "LimeThinking\SpringBundle\Filters\FilterTwo"
}
["extra_value"]=>
bool(true)
}
The Processor merges the values from the config tree with those from the app level config, if there are multiple app level configs then any repeated values are overwritten by those later in the array. The order they are in the array depends on which file imported which with those imported later appearing earlier in the array. Whilst this sounds counter intuitive it is what allows the values in config_dev.yml, which imports config.yml, to overwrite the ones in config.yml.
The Processor will not merge this config into the Service Container's config automatically but returns it as an array, you still need to choose which values from the config to write to the Service Container.
One thing to note is that when merging a configuration in this way is that an exception will be thrown if any values not specified are included in the app's config rather than them just being ignored.
Configuration Nodes
So let's look at the Configuration in some more details and look at some of the node types and their settings. The first thing to note is that the TreeBuilder
uses a fluid interface so we can chain method class, each time you add a node you move to calling its methods, a call to the end()
method moves you back up to the parent.
In the example above I used three types of node, scalar, boolean and array, so have a look at these and the other node types in a bit more detail:
Variable Node
variableNode()
This is the most basic node type and does nothing to enforce the value of the node.
Scalar Node
scalarNode()
A scalar node can be used for the following types: booleans, strings, null, integers, floats. Attempting to set something else to it, such as an array, will result in an exception being thrown.
Boolean Node
booleanNode()
Whilst you can use a scalar node for boolean values using a boolean node will ensure that only boolean values can be set as an exception will be thrown for any other values.
Array Node
arrayNode()
You can use an array node to allow nested config values in the app level configuration. In the above example I then explicitly set the children of the arrays type. Of course we do not always want to specify what every child of the array should be to allow some flexibility in the config. This means there is much more to using Array Nodes, howver I am not going to cover this here but will look at it in a future post. Edit: I have not covered this in a future post but it is not documented in the Symfony2 component documentation
Configuration Settings
Using the correct node type will provide us with some immediate checking of the value to ensure it is the correct type. Additionally further settings for each node can be made to further validate the config. Some of the settings we can use to enforce use are as follows.
Default Value
defaultValue($value)
You can use this to set a default value for the node, if the node is then not given a value in the app level config the default value will be used. If no default value is given and the node is not given a value in the app level config then it will not appear in the processed config.
There are short cut methods available for setting the default value to true, false and null, these are respectively defaultTrue()
, defaultFalse()
and defaultNull()
Required
isRequired()
You can specify that a node is required, if it is set and the node has no value set in the app level config an exception is thrown.
Allow Overwrite
cannotBeOverwritten()
You can use this to say that the value of a node and its children cannot be overwritten. This does not mean that a default value is used and no value can be set in the app level config but that it can only have its value set in one app level config file.
Treat Like
treatTrueLike($value)
This allows you to make your configuration easier for end users, by treating them setting true as something else in the background. For example if you had treatTrueLike('complicatedValue')
then if the node is set to true in the user level config then it will be set to complicatedValue
in the processed config. As well as treatTrueLike()
there is treatFalseLike()
and treatNullLike()
.
These are by no means a full list of the configuration possibilities, whilst there is not much documentation for this available at the moment you can learn a lot by having a look at the Configuration classes in the Bundles included in the standard distribution of Symfony2.