Symfony2: Framework independent controllers part 2: Don't use annotations
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="https://symfony.com/schema/routing"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://symfony.com/schema/routing
https://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
andNotFoundHttpException
). - 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.