Why Symfony's container is fast
When introducing Symfony to developers, I often hear objections such as:
- Why are there so many configuration files?
- Why do I have to write YAML?
- Isn’t it easier to create service objects with PHP?
- All of this configuration must make Symfony really slow!
These are all connected to its Dependency Injection and Config components. On first glance they look over-complicated - “I just want to create some objects!” - but they actually provide many benefits.
In this post we’ll explore answers to these objections, and in the process find the magic behind this misunderstood part of Symfony.
Service definitions, not objects
The most important thing to remember is that Symfony’s container works with definitions of how to build services, not the services themselves.
Let’s look at an example comparing Symfony’s container and Pimple, a very simple container.
(To follow along, run composer require symfony/dependency-injection pimple/pimple
in a new directory).
<?php
include 'vendor/autoload.php';
$symfony = new Symfony\Component\DependencyInjection\ContainerBuilder();
$symfony->register('my-service', stdClass::class);
$symfony->get('my-service');
$pimple = new Pimple\Container();
$pimple['my-service'] = function() {
return new stdClass();
};
$pimple['my-service'];
This should be self-explanatory - we’re registering my-service
in a Pimple and Symfony container.
Notice how we construct the object manually when using Pimple, but we only specify the class name with Symfony.
We’re telling Symfony how to construct the object, but not actually doing it ourselves.
What about constructor arguments?
<?php
$definition = $symfony->register('new-year', DateTime::class);
$definition->setArgument(0, '2019-01-01');
$symfony->get('new-year');
$pimple['new-year'] = function() {
return new DateTime('2019-01-01');
};
$pimple['new-year'];
Again, we don’t actually construct the object ourselves with Symfony. Instead we work with the service’s definition - an instance of Symfony\Component\DependencyInjection\Definition. The methods on that class let us configure any aspect of an object without creating it directly. This is a great opportunity for extension points, and is what makes Symfony’s “bundle” concept so powerful.
Even if the bundle doesn’t support it, it’s easy to override any aspect of a bundle service:
<?php
// In the Acme\SuperBundle code
// $config is configuration for the Acme\SuperBundle.
$symfony->register('acme.super.api_client', Acme\SuperBundle\ApiClient::class);
->setArgument(0, $config['api-key'])
->addMethodCall('setTimeout', [$config['timeout']]);
$symfony->get('acme.super.api_client');
// In an application
$apiClientDefinition = $symfony->getDefinition('acme.super.api_client');
// Change the class to a custom implementation
$apiClientDefinition->setClass(App\ApiClientWithLogging::class);
// Add an argument
$apiClientDefinition->setArgument(1, new Symfony\Component\DependencyInjection\Reference('logger'));
$symfony->get('acme.super.api_client');
// ApiClientWithLogging object with the 'logger' service injected as a second argument
Not so easy with Pimple:
<?php
$pimple['acme.super.api_client'] = function() {
$client = new Acme\SuperBundle\ApiClient($config['api-key']);
$client->setTimeout($config['timeout']);
return $client;
};
// In an application
$pimple->get('acme.super.api_client');
// Too late to change the class name or __construct parameters.
// We have to override it entirely, repeating all the configuration above.
// What if we don't have access to $config?
$pimple['acme.super.api_client'] = function($pimple) {
$client = new App\ApiClientWithLogging($config['api-key'], $pimple['logger']);
$client->setTimeout($config['timeout']);
return $client;
};
Without a definition object, we’re forced to repeat all the logic of the Acme\SuperBundle package. This would get extremely tedious for a large service graph. Imagine having to reconstruct all of the security services from scratch just to change the class name of a security voter!
Loading definitions from files
Working with definitions is great, but surely a bit tedious? That’s where the various loaders come in, allowing you to define these services.
services:
money_maker:
class: App\MoneyMaker
arguments:
- '@logger'
<?php
$loader = new Symfony\Component\DependencyInjection\Loader\YamlFileLoader($symfony, new FileLocator(__DIR__));
$loader->load('services.yaml');
$symfony->get('money_maker');
Again, this would be difficult or impossible to achieve with Pimple.
The container is compiled and dumped to a cache
What about speed? Surely loading all of these configuration files and definition objects is slow?
Not true. Symfony’s container is actually incredibly fast because it’s compiled. The container compiler shares some properties with a conventional language compiler:
- The compiler can optimise and simplify the code during compilation
- Assertions and checks can be performed at compile-time instead of run-time
- The compiled code is fast
To optimise the code and perform various checks, Symfony uses ‘compiler passes’. These passes have various functions, such as:
- Checking service arguments exist and their definitions are defined correctly
- Removing services that were defined but not actually needed
- Replacing service aliases with the actual services
There are many, many more.
Compiler passes cover optimisations and checks, but for speed we can use a PhpDumper
to dump the compiled container to a single PHP file:
<?php
$cacheFile = __DIR__ .'/container_cache.php';
if (!file_exists($cacheFile)) {
$containerBuilder = new Symfony\Component\DependencyInjection\ContainerBuilder();
// Load the service definitions.
// Could be from yaml, xml files, various vendor bundles, etc.
$containerBuilder->register('service', stdClass::class)
->setPublic(true);
// $yamlLoader->load('services.yaml');
// $xmlLoader->load('services.xml');
// $xmlLoader->load('extra_services.xml');
// Compile and dump the container to the cache file
$containerBuilder->compile();
$dumper = new Symfony\Component\DependencyInjection\Dumper\PhpDumper($containerBuilder);
file_put_contents($cacheFile, $dumper->dump(['class' => 'FastContainer']));
}
require $cacheFile;
$container = new FastContainer();
$container->get('service');
This code will create container_cache.php
, but only if it doesn’t exist yet.
Inside is a single class, optimised for building services quickly.
It’s the only file required to build the services, even if they were loaded from yaml or xml files, skipping the need to load those files at all.
Note that we’d need to delete the cache file to add a new service in the above example. A real Symfony application would use a ConfigCache to know to recreate the cache file when the container changes.
After the container has been cached in a typical Symfony app
(depending on your settings, the file could be var/cache/dev/srcApp_KernelDevDebugContainer.php
),
it will be the only file used to construct the services in the entire application!
Each incoming request will be able to skip parsing YAML files, loading bundle configuration, running compiler passes, etc.
A big speedup!
Conclusion
On top of this foundation - service definitions and compilation steps - are many advanced features that really sets Symfony’s container apart:
- Autowiring with no performance impact (the autowiring is handled at compile-time and dumped to the cache)
- Services can be tagged and autoconfigured (e.g. inject all services implementing
AlertInterface
into theAlertAggregator
) - Override any aspect of any service with a compiler pass (e.g. change the class of a bundle’s service to your own implementation)
- Inject environment variables without re-compiling the container
A typical Symfony app will have all of these features set up for you automatically, and in most modern apps all the services will be autowired for you. If you do have to configure the container, however, I hope this post has uncovered some of its mystery.
Remember the most important concept: You create service definitions, not service objects.