PHP & Symfony About PHP and Symfony2 development

Symfony2: Framework independent controllers part 2: Don't use annotations

Posted on by Matthias Noback

In the previous part of this series we decreased coupling of a Symfony controller to the Symfony2 framework by removing its dependency on the standard Controller class from the FrameworkBundle.

Now we take a look at annotations. They were initially introduced for rapid development (no need to create/modify some configuration file, just solve the issues inline!):

namespace Matthias\ClientBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;

/**
 * @Route("/client")
 */
class ClientController
{
    /**
     * @Route('/{id}')
     * @Method("GET")
     * @ParamConverter(name="client")
     * @Template
     */
    public function detailsAction(Client $client)
    {
        return array(
            'client' => $client
        );
    }
}

When you use these annotations, the details action will be executed when the URL matches /client/{id}. A param converter will be used to fetch a Client entity from the database based on the id parameter which is extracted from the URL by the router. The return value of the action is an array. These are used as template variables for rendering the Resources/views/Client/Details.html.twig template.

Well, very nice! All of that in just a couple of lines of code. However, all the things that happen auto-magically out of sight make this controller tightly coupled to the Symfony2 framework. Although it has no explicit dependencies (i.e. no type-hints to other classes), it has several major implicit dependencies. This controller only works when the SensioFrameworkExtraBundle is installed and enabled, for the following reasons:

  • It generates routing configuration based on annotations.
  • It takes care of the conversion of an array return value to an actual Response object.
  • It guesses which template needs to be rendered.
  • It converts the id request parameter to an actual entity.

This might not seem such a big problem at all, but the SensioFrameworkExtraBundle is a bundle, which means it only works in the context of a Symfony2 application. We don't want our controller to be coupled like this to the framework (at least, that is the point of this series!), so we need to remove the dependency.

Instead of using annotations for configuration, we will use actual configuration files and actual PHP code.

Use a proper routing file

First we make sure routes for this bundle are loaded from Resources/config/routing.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<routes xmlns="http://symfony.com/schema/routing"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://symfony.com/schema/routing
        http://symfony.com/schema/routing/routing-1.0.xsd">

    <route id="client.details" path="/client/{id}" methods="GET">
        <default key="_controller">client_controller:detailsAction</default>
    </route>

</routes>

You can also use Yaml, but I prefer XML these days.

Make sure the client_controller service actually exists and don't forget to import the new routing.xml file in your application's app/config/routing.yml file:

MatthiasClientBundle:
    resource: @MatthiasClientBundle/Resources/config/routing.xml

Now we can remove the @Route and @Method annotations from the controller class!

Create the response object yourself

Next, instead of relying on the @Template annotation, you could easily render a template yourself and create a Response object containing the rendered template. You only need to inject the templating engine into your controller manually and explicitly provide the name of the template you want to render:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Component\HttpFoundation\Response;

class ClientController
{
    private $templating;

    public function __construct(EngineInterface $templating)
    {
        $this->templating = $templating;
    }

    /**
     * @ParamConverter(name="client")
     */
    public function detailsAction(Client $client)
    {
        return new Response(
            $this->templating->render(
                '@MatthiasClientBundle/Resources/views/Client/Details.html.twig',
                array(
                    'client' => $client
                )
            )
        );
    }
}

In the service definition of this controller, make sure you inject the templating service as a constructor argument:

services:
    client_controller:
        class: Matthias\ClientBundle\Controller\ClientController
        arguments:
            - @templating

After making these small changes we can also remove the @Template annotation!

Fetch the required data yourself

There's one last step we can take to decrease the coupling of the ClientController to the framework even further: we still depend on the SensioFrameworkExtraBundle for the automatic conversion of an id to an entity. It can not be too hard to fix this! We might just as well fetch the entity ourselves using the entity repository directly:

...
use Doctrine\Common\Persistence\ObjectRepository;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class ClientController
{
    private $clientRepository;
    ...

    public function __construct(ObjectRepository $clientRepository, ...)
    {
        $this->clientRepository = $clientRepository;
        ...
    }

    public function detailsAction(Request $request)
    {
        $client = $this->clientRepository->find($request->attributes->get('id'));

        if (!($client instanceof Client) {
            throw new NotFoundHttpException();
        }

        return new Response(...);
    }
}

The service definition needs to return the right entity repository, which can be accomplished in this way:

services:
    client_controller:
        class: Matthias\ClientBundle\Controller\ClientController
        arguments:
            - @templating
            - @client_repository

    client_repository:
        class: Doctrine\Common\Persistence\ObjectRepository
        factory_service: doctrine
        factory_method: getRepository
        public: false
        arguments:
            - "Matthias\ClientBundle\Entity\Client"

Please also read one of my previous articles about injecting repositories.

Now, finally, there's no need to use annotations anymore, which means our controller could definitely be used outside of a Symfony2 application (i.e. an application that doesn't depend on the Symfony FrameworkBundle, nor the SensioFrameworkExtraBundle). All dependencies are explicit, i.e. to execute the ClientController you need:

  • The Symfony HttpFoundation Component (for Response and NotFoundHttpException).
  • The Symfony Templating Component (for the EngineInterface).
  • Some kind of Doctrine repository implementation (e.g Doctrine ORM, Doctrine MongoDB ODM, etc.).
  • Twig for rendering the templates.

There is one loose end:

  • The template name is still convention-based (it uses the bundle name as a namespace, e.g. @MatthiasClientBundle/...). This is an implicit dependency on the framework, since it registers these bundle namespaces on the Twig Filesystem loader for you. We will address this issue in the next post.

Categories: PHP Symfony

Tags: controller reusability coupling

Comments: Comments