Symfony 2 Controller as a Service

Posted on

The Complete Guide

When developing an application in Symfony2, it is often desirable to use your controllers as a service. There are a few advantages to this, especially if you want to include your controllers in your unit-test suite. Although some may argue that controllers are best tested using functional testing techniques (such as a headless request), I'm not here to argue for or against that. I'm here to explain why and how to do it.

Follow along with this post by visiting the Github repo here. The commits are done in single files and follow the progression of this post for reference.

Disadvantages of the Container

To start, we have a basic Symfony2 Controller already setup using the traditional method:

namespace My\TestBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class LightController extends Controller
{
    public function switchAction($switch)
    {
        $lightSwitch = $this->container->get('light_switch');

        if($switch === "on") {
            $lightSwitch->on();
        } else {
            $lightSwitch->off();
        }

        return $this->redirect($this->generateUrl('light_status'), 301);
    }

    public function statusAction()
    {
        $lightSwitch = $this->container->get('light_switch');
        return $this->render('MyTestBundle:Light:status.html.twig', array('status' => $lightSwitch->getStatus()));
    }
}

This controller is a simple action to activate a light switch. Looking through the code, we can see one simple dependency: light_switch. From basic inspection of the code, there's no way to see exactly what type of dependency this is. Explicit type-hinting is required for this. What if the light_switch definition changes in the future, but the type hints don't? And what if there are no type hints, as shown above? Developers now need to delve into the Service Container to see the definitions, find the class API and work from there.


There's a few definitions in there.

As far as testing goes, there are two ways this controller could be tested:

  • Use Unit Testing and "fake" the container to return mock classes from the get() method.
  • Use Functional Testing and test the entire HTTP call.

Neither option is very good, and I'll explain why.

Unit Testing the Service Container

When Unit Testing the service container, as I mentioned previously, you would mock the get() method, so that a call to get("session") would return a mocked Session object. While this is a perfectly valid method, there's a few reasons why not to do this as well.

This method encourages further use of the service container in the controller. Developers will have to remember that if they add a service to the controller, they will have to add it to the test. These new dependencies are not enforced by a language constraint - i.e. adding a new dependency to a controller and not to the test may not break the code. This means dependencies could go untested if forgotten.

The fact that the service container accepts a string is also a problem - it introduces more developer error into the equation. It's always a positive thing to reduce developer error and minimize runtime errors, especially in a non typesafe language such as PHP.

Functional Testing

While functional testing is a perfectly valid method, it does not suit the specific use case here. The point of Unit Testing is to test a small amount of code using a large number of possibilities. Using a Functional Test such as a headless browser is the opposite - it tests a large amount of code (normally the entire framework) for a smaller number of possibilities.

Dependency Injection

Instead of relying on the Symfony Service Container, what if the dependencies were directly injected in using the constructor. Here's what the controller looks like now:

namespace My\TestBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use My\TestBundle\Light\LightSwitch;

class LightController extends Controller
{
    protected $lightSwitch;

    public function __construct(LightSwitch $lightSwitch)
    {
        $this->lightSwitch = $lightSwitch;
    }

    public function switchAction($switch)
    {        
        if($switch === "on") {
            $this->lightSwitch->on();
        } else {
            $this->lightSwitch->off();
        }

        return $this->redirect($this->generateUrl('light_status'), 301);
    }

    public function statusAction()
    {
        return $this->render('MyTestBundle:Light:status.html.twig', array('status' => $this->lightSwitch->getStatus()));
    }
}

This option is much cleaner from a readability standpoint - it is immediately known what types the dependencies are. The dependencies are only passed in to the controller, not magically fetched by the service container. Testing is also much more simple and stable - the mocked objects can be provided directly to the controller.

Configuring the Controller

If we try and run this, we would get a PHP exception:

Catchable Fatal Error: Argument 1 passed to My\TestBundle\Controller\LightController::__construct() must be an instance of My\TestBundle\Light\LightSwitch, none given

This is because Symfony needs to know what to pass the controller. The controller needs to be added as a service so that dependencies can be passed in. This is done by opening the services.yml file in your /Resources/config folder in the bundle. The LightController can be added as a service:

services:
    light_switch:
        class: My\TestBundle\Light\LightSwitch
        arguments: [@session]

    light_controller:
        class: My\TestBundle\Controller\LightController
        arguments: [@light_switch]

The new service is named light_controller. As can be seen above, the light_switch service is passed directly into the controller as a constructor argument. The arguments directive can take in constants, other services, and even Symfony parameters and pass them into the constructor - it is pretty powerful.

Routing

Next, the Symfony Routing needs to be able to dispatch the route to the service. This can be done in the routing.yml file - also in the /Resources/config directory. The defaults parameter must be modified so that the name of the controller matches the service definition. The standard syntax is to list the controller using the bundle:controller:action format, where the Bundle is the Bundle Name, the controller is the name of the controller class in the Controller namespace, and Action is the name of the action being dispatched (if the action is index for example, indexAction() will be called). We will replace the standard routing syntax with a new syntax: Service:action.

light_switch:
    path:     /light/switch/{switch}
    defaults: { _controller: light_controller:switchAction }

light_status:
    path:     /light/status
    defaults: { _controller: light_controller:statusAction }

Something to be noted is that the full function name must be given as the action when using service definitions (i.e. switchAction instead of switch). This differs from the traditional routing where Action can be omitted.

The Symfony Default Controller

As far as a pure Controller as a Service - it's now all setup. However, all of the Symfony Controller methods such as render() and redirect() will not work. This is because they rely on the container - the one thing we just got away from. If we now try and run our code, we get the following error:

Error: Call to a member function get() on a non-object

The Symfony Controller methods require the container to be set. If you still want a Controller as a Service, and allow the usage of the Symfony Controller methods, continue reading here.

Automated Setting of the Container

In order to facilitate the usage of the default Symfony Controller, we're going to allow the Service Container to automatically inject itself into the controller based on a tag. A tag is an attribute you can add to a service definition to modify the way the Service Container builds that service. We're going to add a simple tag to our light_controller service:

light_controller:
    class: My\TestBundle\Controller\LightController
    arguments: [@light_switch]
    tags: 
        - {name: container_aware}

This container_aware tag will tell Symfony to inject the container into our controller. Unfortunately, Symfony does not provide us with this functionality out of the box - the logic to perform this must be created. We can use something called a Compiler Pass to achieve this.

Building the CompilerPass

When the Service Container is being compiled, it runs any registered CompilerPassInterface objects. These can modify the service container any way it wants - it can change parameters, add method calls, and swap service definitions. All of this happens before the container is built and wired up.

We're going to create a simple Compiler Pass to look for any objects with the container_aware tag and call the setContainer method on them. This method already belongs to our controller, as it is part of the Symfony Controller class. This code will live in the DependencyInjection/Compiler folder of the bundle. The class will implement the CompilerPassInterface by providing a process method.

namespace My\TestBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;

class ContainerAwarePass implements CompilerPassInterface
{    
    public function process(ContainerBuilder $container) 
    {
        // Get an array of tagged services.
        $taggedServices = $container->findTaggedServiceIds('container_aware');

        // Add some references to the services.
        foreach($taggedServices as $id => $attributes)
        {
            $service = $container->getDefinition($id);
            $service->addMethodCall('setContainer', [new Reference('service_container')]);
        }
    }
}

We are fetching all service Id's that are tagged with container_aware, getting their definition, and adding a method call. The setContainer method will be called with one argument: the reference to the service container.

The final step in making this complete is registering the compiler pass with the framework. In your root Bundle file, the build function can be overridden to register the pass:

namespace My\TestBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use My\TestBundle\DependencyInjection\Compiler\ContainerAwarePass;

class MyTestBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        parent::build($container);        
        $container->addCompilerPass(new ContainerAwarePass());
    }
}

The LightController now acts exactly as it did before. The advantages are that you can now explicitly pass in references instead of depending on the service container. The default Symfony Controller methods are also still accessible by tagging your controller as container_aware.

comments powered by Disqus