Refactoring a controller in Symfony

When you first start using Symfony you will probably start with the code examples from the official documentation. This is fine and much recommended and I would encourage anyone to do so. These examples are amazing to get a good start in Symfony and provide quick feedback.

After a while though, you should consider to write cleaner and more separated code. Having well structured code will help you immensely to reuse code and write tests.

This is part of a controller action we wrote about one year ago. It follows the same pattern, which can be found in the documentation. It builds a registration form and provides all sort of content to render the view.

/** Acme/RegistrationBundle/Controller/RegistrationController */
..
/**
 * @Route("/{id}", name="registration")
 * @Method({"get","post"})
 */
public function registrationAction(Request $request, Event $event)
{
  $entityManager = $this->getDoctrine()->getManager();
  $country = $entityManager->getRepository('RegistrationBundle:Country')->find($this->get('request')->getSession()->get('country'));

  if (!$event->isAvailableInCountry($country)) {
    throw new SymfonyComponentHttpKernelExceptionHttpException(404, 'This event is not available in your country');
  }

  if (!$event->hasActiveRegistration()) {
    return $this->render('RegistrationBundle:Registration:disabled.html.twig', array(
      'event' => $event
    ));
  }

  $registration = new Registration();
  $registration->setEvent($event);

  $formData = $request->request->get('registration_form');
  $form = $this->createForm(new RegistrationType($entityManager), $registration, array(
    'aprimo_function' => null !== $formData['aprimoFunction'] ? $formData['aprimoFunction'] : null,
    'aprimo_primary_industry' => null !== $formData['aprimoPrimaryIndustry'] ? $formData['aprimoPrimaryIndustry'] : null,
    'aprimo_sub_industry' => null !== $formData['aprimoSubIndustry'] ? $formData['aprimoSubIndustry'] : null,
    'country_code' => null !== $formData['country'] ? $entityManager->getRepository('RegistrationBundle:CountryOfResidence')->findOneById($formData['country'])->getCountryCode() : null,
    'has_reseller_selection' => $event->hasResellerSelection(),
    'validation_groups' => $this->getValidationGroups($country->getOptions(), $event->hasResellerSelection()),
    'country_options' => $country->getOptions(),
    'locale_fallback' => $this->container->get('session')->get('locale_fallback'),
    'country' => $country,
  ));

  if ('POST' == $request->getMethod()) {
    $form->bind($request);
    if ($form->isValid()) {
      /** do stuff with valid data */
    }
  }

  return $this->render('RegistrationBundle:Registration:index.html.twig', array(
    'event' => $event,
    'form' => $form->createView()
  ));

}

The biggest issue with this code is that the controller holds way too much logic. For example, at this point the reader should not be concerned with how the form is put together. So let’s put that part in a form factory, which we will declare as a service.

Form factory

The form definition was already moved to a FormType file, which should be the first step, if that is not the case yet. Even for small forms I would recommend using FormTypes, as re-usability is king.

/** Acme/RegistrationBundle/Factory/RegistrationFormFactory */

class RegistrationFormFactory
{
  private $formFactory;
  private $entityManager;
  private $country;
  private $session;

  public function __construct(FormFactory $formFactory, EntityManager $entityManager, Country $country, Session $session)
  {
    $this->formFactory = $formFactory;
    $this->entityManager = $entityManager;
    $this->country = $country;
    $this->session = $session;
  }

  public function create($registration = null, $formData = array())
  {
    $type = new RegistrationType($this->entityManager);
    $registration = $registration ?: new Registration();
    $event = $registration->getEvent();
    $options = array(
      'aprimo_function' => isset($formData['aprimoFunction']) ? $formData['aprimoFunction'] : null,
      'aprimo_primary_industry' => isset($formData['aprimoPrimaryIndustry']) ? $formData['aprimoPrimaryIndustry'] : null,
      'aprimo_sub_industry' => isset($formData['aprimoSubIndustry']) ? $formData['aprimoSubIndustry'] : null,
      'country_code' => isset($formData['country']) && $formData['country'] != '' ? $this->entityManager->getRepository('RegistrationBundle:CountryOfResidence')->findOneById($formData['country'])->getCountryCode() : null,
      'has_reseller_selection' => $event->hasResellerSelection(),
      'country_options' => $this->country->getOptions(),
      'locale_fallback' => $this->session->get('locale_fallback'),
      'country' => $this->country,
    );

    return $this->formFactory->create($type, $registration, $options);
  }

}

Now we can declare the form factory as service and simply inject everything we need into the controller, instead of dragging it through several classes.


<service id="factory.registration_form" class="AcmeRegistrationBundleFactoryRegistrationFormFactory">
  <argument type="service" id="form.factory" />
  <argument type="service" id="doctrine.orm.entity_manager" />
  <argument type="service" id="context.country" />
  <argument type="service" id="session" />
</service>

With the service ready we can go back to the controller and use the dependency injection container to replace the form creation process with a call to the form create method of the service.


/**
 * @Route("/{id}", name="registration_event")
 * @Method({"get","post"})
 * @Template()
 */
public function registrationAction(Request $request, Event $event)
{
  /** @var Country $country */
  $country = $this->container->get('factory.country')->getCountry();
  if (!$event->isAvailableInCountry($country)) {
    throw new SymfonyComponentHttpKernelExceptionHttpException(404, 'This event is not available in your country');
  }
  if (!$event->hasActiveRegistration()) {
    return $this->render('RegistrationBundle:Registration:disabled.html.twig', array(
      'event' => $event
    ));
  }

  /** @var Registration $registration */
  $registration = $this->container->get('manager.registration')->createNewForEvent($event);
  $form = $this->container->get('factory.registration_form')->create(
    $registration,
    $request->request->get('registration_form')
  );

  if ('POST' == $request->getMethod()) {
    $form->bind($request);
    if ($form->isValid()) {
      /** do something */
    }
  }

  return $this->render('RegistrationBundle:Registration:index.html.twig', array(
    'event' => $event,
    'form' => $form->createView()
  ));
}

In addition we move the two validations into an extra function, which leaves only the most important logic in the controller class.


/**
* @Route("/{id}", name="registration_event")
* @Method({"get","post"})
* @Template()
*/
public function registrationAction(Request $request, Event $event)
{
  /** @var Country $country */
  $country = $this->container->get('factory.country')->getCountry();
  $this->checkIfRegistrationIsValidForCountryAndEvent($country, $event);

  /** @var Registration $registration */
  $registration = $this->container->get('manager.registration')->createNewForEvent($event);
  $form = $this->container->get('factory.registration_form')->create(
    $registration,
    $request->request->get('registration_form')
  );

  if ('POST' == $request->getMethod()) {
    $form->bind($request);
    if ($form->isValid()) {
      /** do something */
    }
  }

  return $this->render('RegistrationBundle:Registration:index.html.twig', array(
    'event' => $event,
    'form' => $form->createView()
  ));
}

And voilá, the controller looks much cleaner and we have some nice little isolated parts, which can be unit tested easily.

Leave a Reply

Your email address will not be published. Required fields are marked *