Custom symfony2 config loader

Warning: This blogpost has been posted over two years ago. That is a long time in development-world! The story here may not be relevant, complete or secure. Code might not be complete or obsoleted, and even my current vision might have (completely) changed on the subject. So please do read further, but use it with caution.
Posted on 30 Jan 2013
Tagged with: [ custom ]  [ loader ]  [ PHP ]  [ symfony2

It happens more and more: large projects where your symfony2 site is just a small part in the big picture. Lots of additional components might even play a bigger part, especially when you are dealing with asynchronous components  which are connected through message queues for instance. So the question is: we want to make sure that all your components are using the same settings, be it your symfony2 project, your bash-scripts, 3rd python application and whatnot. How do we keep this all in sync?

Our first idea is obvious: symfony2 uses by default a parameters.yml file, which gets imported by your configuration (config.yml). A setup like the next example is common:

    parameters:
      solr.host: 10.10.1.10
      solr.port: 8080
      solr.path: /solr
      solr.core: mycore
    imports:
      - { resource: parameters.yml }
    
    ....
    
    markup_solarium:
        clients:
            default:
                host: %solr.host%
                port: %solr.port%
                path: %solr.path%
                core: %solr.core%
                timeout: 5

Basically, all environment specific parameters are set inside your parameters.yml, and we use the %% placeholders to add them to the config.yml. This means it’s very easy to have a development parameters.yml where in this case the solr clients points to 127.0.0.1 instead of the production solr instance.

So nothing new here and this setup works perfectly, provided that your symfony2 project is the ONLY thing that needs to be configured. As soon as you add multiple other components (like gearman or activemq workers that need to connect to solr too), they need to share this configuration. Duplicating the settings is not an option, even if you automate this, it will be a pain to debug when issues occur. Of course, we COULD have them point to the parameters.yml file too, but I think this is the wrong approach. Again: symfony2 isn’t your main component, so why would it act like it by being in charge of the configuration.

Personally, I find it a better approach to have a generic configuration file on a fixed point, somewhere in your /etc directory. Afteral, this is the directory where configurations tend to live. So we could let our config.yml files point to /etc/bigproject/parameters.yml.

But there are still issues: first of all, yaml files are really neat for configuration, and by default I  would use them. However, yaml files can be a pain to read from other systems. For instance, creating shell-scripts  that read yaml is hard at best. INI-files however, are pretty easy to read, even with bash.

This is why in our project we are using a generic INI file which holds all settings that needs to be shared through multiple components.

But of course, things aren’t that easy (otherwise no point in writing this blogpost). The standard INI resource loader of symfony2 (Symfony\Component\DependencyInjection\Loader\IniFileLoader.php) only reads parameters from the [parameters] section. Everything else gets ignored. Again, because we don’t want to treat symfony2 as our “main” system, this is not acceptable. My generic INI configuration should look something like this:

    [solr]
      host = 10.10.1.10
      port = 8080
      path = /solr
      core = mycore

As you can see, I want to have different sections - namespaces, if you will -, because it’s much easier to isolate config settings and to adjust them (which means we can quite easily use automated systems like augeas too).

Now, let’s go and create a new resource handler, dedicated to our conf file. First, we need to create this custom loader:

<?php
namespace BigProject\MainBundle;
use Symfony\Component\Config\Resource\FileResource;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Loader\FileLoader;
class ConfFileLoader extends FileLoader
{
    /**
     * Loads a resource.
     *
     * @param mixed  $file The resource
     * @param string $type The resource type
     *
     * @throws InvalidArgumentException When ini file is not valid
     */
    public function load($file, $type = null)
    {
        $path = $this->locator->locate($file);
        $this->container->addResource(new FileResource($path));
        $result = parse_ini_file($path, true);
        if (false === $result || array() === $result) {
            throw new InvalidArgumentException(sprintf('The "%s" file is not valid.', $file));
        }
        foreach ($result as $key => $section) {
            foreach ($section as $name => $value) {
                $this->container->setParameter($key.".".$name, $value);
            }
        }
    }
    /**
     * Returns true if this class supports the given resource.
     *
     * @param mixed  $resource A resource
     * @param string $type     The resource type
     *
     * @return Boolean true if this class supports the given resource, false otherwise
     */
    public function supports($resource, $type = null)
    {
        return is_string($resource) && 'conf' === pathinfo($resource, PATHINFO_EXTENSION);
    }
}

This file can be placed inside a bundle in the root, or somewhere else if you find a better place for it. This PHP class which extends FileLoader, just does a parse_ini_file() on a given file. Instead of just parsing the “parameters” section, we parse ALL sections. To make sure we don’t get name-clashes, we prefix each found ini-setting with the section name. If we were using our mainconfig.conf file as above, we would use the parameter: solr.host and solr.path. Our mongoDB host could be something like mongodb.host, so they don’t clash.

The supports() method returns true when this loader supports the given file. We only check against the “conf” extension, but since this is a very specific loader, you could also check against the full-path, but it might make the system a bit less flexible.

The second part is about actually connecting our loader so it all works. The standard loaders are loaded through the Symfony\Component\HttpKernel\Kernel abstract class, in the getContainerLoader() method. By simple overriding this method in our AppKernel.php, we can add (or remove) loaders quite easily:

class AppKernel extends Kernel
{
  
  ...
  
  /**
   * Add loader to load settings from /etc/bigproject/settings.conf
   */
  protected function getContainerLoader(ContainerInterface $container)
  {
    $cl = parent::getContainerLoader($container);

    // Add additional loader to the resolver
    $resolver = $cl->getResolver();
    $resolver->addLoader(new BigProject\MainBundle\ConfFileLoader($container, new FileLocator(array())));

    return $cl;
  }
  
}

We just execute the parent getContainerLoader(), which returns the default containerloader. Next, we extract the resolver, and add our new loader to it.

The only thing left to do, is make sure our config.yml loads our generic conf file:

    imports:
        - { resource: /etc/bigproject/settings.conf }
        - { resource: parameters.yml }

Because we still load our parameters.yml, this file can be used to set symfony2 specific parameters just like you are used to.