Symfony2: Moving Away From the Base Controller

When you start learning Symfony2 using the book, you are introduced to the base controller. The base controller has some helpful methods to make common tasks easier. After a while though you may want to move away from extending this controller, in fact, it is the advised best practice for controllers in shared bundles. When you do, you will no longer have access to these useful methods, in this post I am going to look at how to accomplish the same tasks without them.

I have created a simple controller for some basic form interaction with a model which uses the base controllers methods. I am going to go through how to change this to a service which does not extend the base controller. The starting point looks like this:

<?php

namespace LimeThinking\SpringBundle\Controller;

use LimeThinking\SpringBundle\Entity\Testimonial;
use LimeThinking\SpringBundle\Form\TestimonialType;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class TestimonialController extends Controller
{

    public function viewAction($id)
    {
        $testimonial = $this->getTestimonial($id);

        return $this->render(
             'LimeThinkingSpringBundle:Testimonial:show.html.twig',
             array(
                'testimonial' => $testimonial,
             )
        );
    }

    public function addAction()
    {
        $testimonial = new Testimonial();
        return $this->renderForm($testimonial);
    }

    public function editAction($id)
    {
        $testimonial = $this->getTestimonial($id);
        return $this->renderForm($testimonial);
    }

    protected function renderForm($testimonial)
    {
        $form = $this->createForm(new TestimonialType(), $testimonial);
        $request = $this->get('request');
        if ($request->getMethod() == 'POST')
        {
            $form->bindRequest($request);

            if ($form->isValid())
            {
                $em = $this->getDoctrine()->getEntityManager();
                $em->persist($testimonial);
                $em->flush();
                $uri = $this->generateUrl(
                    'admin_testimonial', 
                    array('id' => $testimonial->getId())
                );
                return $this->redirect($uri);
            }
        }

        return $this->render(
             'LimeThinkingSpringBundle:Testimonial:form.html.twig',
             array(
                'form' => $form->createView(),
             )
        );
    }

    protected function getTestimonial($id)
    {
        $testimonial = $this->getDoctrine()
            ->getEntityManager()
            ->getRepository('LimeThinkingSpringBundle:Testimonial')
            ->find($id);

        if (!$testimonial) {
            throw $this->createNotFoundException('Testimonial not found.');
        }

        return $testimonial;
    }


}

It uses a form type object LimeThinking\SpringBundle\Form\TestimonialType for the form which looks like this:

<?php
namespace LimeThinking\SpringBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

class TestimonialType extends AbstractType
{
    public function buildForm(FormBuilder $builder, array $options)
    {
        $builder->add('name')
        ->add('intro')
        ->add('quotation');
    }
}

The controller as it stands has actions for displaying, adding and editing testimonials. I have extracted the common tasks, retrieving a testimonial from the database and rendering the form, into their own methods. It is routed to like this:

admin_testimonial_add:
    pattern:  /admin/testimonials/add
    defaults: { _controller: LimeThinkingSpringBundle:Testimonial:add}

admin_testimonial:
    pattern:  /admin/testimonials/{id}
    defaults: { _controller: LimeThinkingSpringBundle:Testimonial:view}
    
admin_testimonial_edit:
    pattern:  /admin/testimonials/{id}/edit
    defaults: { _controller: LimeThinkingSpringBundle:Testimonial:edit}

Removing Helper Methods

To start with I am going to change the helper methods to use services still retrieved using the get() helper method which is a short cut for getting services from the container. After this is complete we can convert to a service and inject the required services instead.

The methods we need to replace are:

  • getDoctrine()
  • generateUrl()
  • render()
  • createForm()
  • redirect()
  • createNotFoundException()
  • get()

Once all these are gone we can stop extending Symfony\Bundle\FrameworkBundle\Controller\Controller. To start we will actually introduce more use of get() as this allows us to access services from the Dependency Injection Container (aka Service Container).

getDoctrine()

The easiest to remove is getDoctrine():

$em = $this->getDoctrine()->getEntityManager();

becomes

$em = $this->get('doctrine')->getEntityManager();

generateUrl()

Next up generateUrl():

$uri = $this->generateUrl('admin_testimonial', array('id' => $testimonial->getId()));          

becomes

$uri = $this->get('router')->generate(
    'admin_testimonial', 
    array('id' => $testimonial->getId())
);          

render()

return $this->render(
    'LimeThinkingSpringBundle:Testimonial:form.html.twig',
        array(
            'form' => $form->createView(),
        )
    );

becomes:

return $this->get('templating')->renderResponse(
    'LimeThinkingSpringBundle:Testimonial:form.html.twig',
        array(
            'form' => $form->createView(),
        )
    );

createForm()

$form = $this->createForm(new TestimonialType(), $testimonial);  

becomes:

$form = $this->get('form.factory')->create(new TestimonialType(), $testimonial);  

redirect()

Now for a couple that are slightly different:

return $this->redirect($uri);

becomes:

return new RedirectResponse($uri);

We also need to add:

use Symfony\Component\HttpFoundation\RedirectResponse;

to the use statements at the top of the file.

createNotFoundException()

throw $this->createNotFoundException('Testimonial not found.');

becomes:

throw new NotFoundHttpException('Testimonial not found.');

with:

use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

added to the use statements.

get()

    $this->get('service_name');

is just a shortcut for

    $this->container->get('service_name');

So we can replace all the get() calls throughout.

Now that all the methods from the base controller have been removed we can change to extending ContainerAware instead. doing so means that the container is available to the controller. So the class now looks like this:

<?php

namespace LimeThinking\SpringBundle\Controller;

use LimeThinking\SpringBundle\Entity\Testimonial;
use LimeThinking\SpringBundle\Form\TestimonialType;
use Symfony\Component\DependencyInjection\ContainerAware;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

class TestimonialController extends ContainerAware
{

    public function viewAction($id)
    {
        $testimonial = $this->getTestimonial($id);

        return $this->container->get('templating')->renderResponse(
             'LimeThinkingSpringBundle:Testimonial:show.html.twig',
             array(
                'testimonial' => $testimonial,
             )
        );
    }

    public function addAction()
    {
        $testimonial = new Testimonial();
        return $this->renderForm($testimonial);
    }

    public function editAction($id)
    {
        $testimonial = $this->getTestimonial($id);
        return $this->renderForm($testimonial);
    }

    protected function renderForm($testimonial)
    {
        $form = $this->container->get('form.factory')->create(
            new TestimonialType(), 
            $testimonial
        );
        $request = $this->container->get('request');
        if ($request->getMethod() == 'POST')
        {
            $form->bindRequest($request);

            if ($form->isValid())
            {
                $em = $this->container->get('doctrine')->getEntityManager();
                $em->persist($testimonial);
                $em->flush();
                $uri = $this->container->get('router')->generate(
                    'admin_testimonial', 
                    array('id' => $testimonial->getId())
                );
                return new RedirectResponse($uri);
            }
        }

        return $this->container->get('templating')->renderResponse(
             'LimeThinkingSpringBundle:Testimonial:form.html.twig',
             array(
                'form' => $form->createView(),
             )
        );
    }

    protected function getTestimonial($id)
    {
        $testimonial = $this->container->get('doctrine')
            ->getEntityManager()
            ->getRepository('LimeThinkingSpringBundle:Testimonial')
            ->find($id);

        if (!$testimonial) {
            throw new NotFoundHttpException('Testimonial not found.');
        }

        return $testimonial;
    }


}

Defining the Controller as a Service

We can also now move to defining our controller as a service. See this cookbook recipe and my post Symfony2: Controller as Service for details on how to do this and how to set up an Dependency Injection extension to load a service definition for a bundle. In this case we would change to routing to the controller like this:

admin_testimonial_add:
    pattern:  /admin/testimonials/add
    defaults: { _controller: lime_thinking_spring.testimonial:addAction}

admin_testimonial:
    pattern:  /admin/testimonials/{id}
    defaults: { _controller: lime_thinking_spring.testimonial:viewAction}
    
admin_testimonial_edit:
    pattern:  /admin/testimonials/{id}/edit
    defaults: { _controller: lime_thinking_spring.testimonial:editAction}

with a services XML config like this:

<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="lime_thinking_spring.testimonial"
        class="LimeThinking\SpringBundle\Controller\TestimonialController" 
                 >
                 <call method="setContainer">
                      <argument type="service" id="service_container" />
                 </call>
        </service>       
        
    </services>

</container>

Note that the container is explicitly injected into the service using the method defined in ContainerAware as this is no longer automatically done.

Injecting Services

Now for the more controversial bit. In my opinion injecting the container in this way is not the correct way to use it. I have written about this in these posts: When Dependency Injection goes Wrong and In Defence of Dependency Injection Containers. This is not the opinion of everyone and not an official best practice, it is just my recommendation.

I have also looked at how to inject dependencies in Symfony2 in these posts: Symfony2: Dependency Injection Types and Symfony2: Injecting Dependencies Step by Step. So I am not going to go over how to do this in detail again this time, the end result of removing the services is that the class looks like this:

<?php

namespace LimeThinking\SpringBundle\Controller;

use LimeThinking\SpringBundle\Entity\Testimonial;
use LimeThinking\SpringBundle\Form\TestimonialType;
use Symfony\Bundle\DoctrineBundle\Registry;
use Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\RouterInterface;

class Testimonial
{

    protected $doctrine;
    protected $formFactory;
    protected $templating;
    protected $request;
    protected $router;

    public function __construct(Registry $doctrine,
                                FormFactoryInterface $formFactory,
                                EngineInterface $templating,
                                Request $request,
                                RouterInterface $router)
    {
        $this->doctrine    = $doctrine;
        $this->formFactory = $formFactory;
        $this->templating  = $templating;
        $this->request     = $request;
        $this->router      = $router;
    }

    public function viewAction($id)
    {
        $testimonial = $this->getTestimonial($id);

        return $this->templating->renderResponse(
             'LimeThinkingSpringBundle:Testimonial:show.html.twig',
             array(
                'testimonial' => $testimonial,
             )
        );
    }

    public function addAction()
    {
        $testimonial = new Testimonial();
        return $this->renderForm($testimonial);
    }

    public function editAction($id)
    {
        $testimonial = $this->getTestimonial($id);
        return $this->renderForm($testimonial);
    }

    protected function renderForm($testimonial)
    {
        $form = $this->formFactory->create(new TestimonialType(), $testimonial);
        if ($this->request->getMethod() == 'POST')
        {
            $form->bindRequest($this->request);

            if ($form->isValid())
            {
                $em = $this->doctrine->getEntityManager();
                $em->persist($testimonial);
                $em->flush();
                $uri = $this->router->generate(
                    'admin_testimonial', 
                    array('id' => $testimonial->getId())
                );
                return new RedirectResponse($uri);
            }
        }

        return $this->templating->renderResponse(
             'LimeThinkingSpringBundle:Testimonial:form.html.twig',
             array(
                'form' => $form->createView(),
             )
        );
    }

    protected function getTestimonial($id)
    {
        $testimonial = $this->doctrine
            ->getEntityManager()
            ->getRepository('LimeThinkingSpringBundle:Testimonial')
            ->find($id);

        if (!$testimonial) {
            throw new NotFoundHttpException('Testimonial not found.');
        }

        return $testimonial;
    }


}

and the services XML like this:

<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="lime_thinking_spring.testimonial"
        class="LimeThinking\SpringBundle\Controller\TestimonialController" 
                 >
                     <argument type="service" id="doctrine" />
	             <argument type="service" id="form.factory" />
	             <argument type="service" id="templating" />
	             <argument type="service" id="lime_thinking_spring.request" />
	             <argument type="service" id="router" />
        </service>       

        <service id="lime_thinking_spring.request" 
                 class="Symfony\Component\HttpFoundation\Request"/>
        
    </services>

</container>

Edit: As per Stof's comment the predefined request service should be used. As this is scoped to request the controller will also need to be scoped to request to avoid scope widening. The services.xml should then look like this:

<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="lime_thinking_spring.testimonial" scope="request"
        class="LimeThinking\SpringBundle\Controller\TestimonialController" 
                 >
                     <argument type="service" id="doctrine" />
	             <argument type="service" id="form.factory" />
	             <argument type="service" id="templating" />
	             <argument type="service" id="request" />
	             <argument type="service" id="router" />
        </service>       

    </services>

</container>

There are still some objects being created using the new keyword. These are all effectively value objects though rather than services. That is to say that they hold specific values and would not be reusable in other classes in the same way that the other injected services are. The RedirectResponse, for example, is specific to the testimonial not being found and is not something we could inject using the container. There is an argument that this is still a dependency that should be removed. If you are that way inclined then a way to do it would be to instead inject Factory objects to create these objects on demand. The factory could then be mocked for testing or a different factory used if you wanted to change the object returned. If you do want to do this then at the moment you will have to write your own though.