Learning A Little More About Forms


Towards the end of the previous section we had the foundations of our Contact Form implementation. In this section we are going to expand on this a little further, looking at:

  • extracting the set-up of our form into a separate class;
  • submitting forms to different controller actions;
  • displaying flash messages, and why you might want too;
  • potential 'gotchas' around redirects
  • customising the form with styles, labels, and options

We're going to start by extracting out the creation of the Contact Form from our controller action into its own class. This class will be our "Form Type", or more specifically, our "Contact Form Type".

In my experience, one of the most confusing parts of working with Symfony's Form Component is that the Form Type is not "the form". When working with HTML forms you can create your input elements and textarea's, your selects and your buttons. In Symfony you will never - directly - do this.

Instead, you will use objects to describe your form, and behind the scenes the Form Component will convert this information into an HTML form representing what you described. This is less confusing when seen than it may sound currently.

Now, even more strangely, you don't necessarily even need to render any HTML whatsoever to work with the Form Component. This is a more advanced topic, but if you continue with learning Symfony and move onto some of the more advanced setups such as a JSON API then you will more than likely encounter this. But you really needn't concern yourself with this at the moment.

Ok, so we have this code currently:

<?php

// /src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

class SupportController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(Request $request)
    {
        $form = $this->createFormBuilder()
            ->add('from', EmailType::class)
            ->add('message', TextareaType:class)
            ->add('send', SubmitType::class)
            ->getForm()
        ;

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // do interesting things here
        }

        return $this->render('support/index.html.twig', [
            'our_form' => $form->createView(),
        ]);

The issue here may not be immediately obvious, but as our applications grow in size and complexity, there is a downside to working this way that is going to become more and more visible.

From a high level, we start off inside indexAction by creating a form, and by using the add method chain, we describe the fields we wish to appear on that form.

Following this, we determine if the form has been submitted or not, and if so, we can work with the submitted form data.

Finally, we always render / display the Twig template that contains the HTML representation of our form.

Now, the issue here is that if we want to re-use our form in other parts of our application then we are going to have to copy / paste this entire section:

$form = $this->createFormBuilder()
    ->add('from', EmailType::class)
    ->add('message', TextareaType:class)
    ->add('send', SubmitType::class)
    ->getForm()
;

Whenever we need to copy / paste we should be thinking: Can this be done in a more efficient way?

As soon as we copy / paste (or duplicate) this code, we now have two (or more) places that we need to update in future should we ever wish to change this form. We therefore have two (or more) potential sources of bugs.

This may not seem like a big deal, or even something that you will do very often, but re-using forms is more common than you might think. Heck, we are about to cover one way that you may re-use a form inside the same controller.

Let's extract this code out of the controller and into its own separate class:

<?php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;

class ContactFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('from', EmailType::class)
            ->add('message', TextareaType::class)
            ->add('send', SubmitType::class)
        ;
    }
}

On the surface, this looks more confusing than what we had.

We've gone from 5 lines to 21 (not all lines of code, admittedly), and now we have two different places (the original Controller action, and this new ContactFormType) where one concept lives. Say what?

Stay with me for a few moments longer.

To complete this example before discussing further, we must also remember to update the Controller code to now use this ContactFormType:

<?php

// /src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Form\Type\ContactFormType;

class SupportController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(Request $request)
    {
        $form = $this->createForm(ContactFormType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // do interesting things here
        }

        return $this->render('support/index.html.twig', [
            'our_form' => $form->createView(),
        ]);

There's the obvious change here of replacing the form builder lines with the line:

$form = $this->createForm(ContactFormType::class);

And there's the less obvious change in the use statements:

use AppBundle\Form\Type\ContactFormType;

instead of:

use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

Again, the benefits of this compound should we need to re-use the Contact Form in multiple places inside our application.

If your thinking that this is a lot of self-aggrandising ceremony to make ourselves feel more like superior developers then I understand your point of view. This does feel like being clever for the sake of it.

The truth is that the benefits of doing this become more obvious in larger applications. And these benefits are somewhat difficult to show in a small tutorial like this. Just be aware at this stage that working in this way will make your life as a developer that much easier when you begin working on "real world" applications.

Let's take another look at the ContactFormType code:

<?php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;

class ContactFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('from', EmailType::class)
            ->add('message', TextareaType::class)
            ->add('send', SubmitType::class)
        ;
    }
}

When I was first starting with Symfony the next question I had here was:

public function buildForm(FormBuilderInterface $builder, array $options)

When / where do I call this, and where do I get a $builder, and what are these $options?

The first thing to note then is that you won't have to call buildForm yourself. This is handled behind the scenes by the Form Component as part of:

$form = $this->createForm(ContactFormType::class);

In your controller action. If you use PHPStorm you can ctrl+click this method to follow the calls through. It's still very confusing, I admit, but what's happening here is not magic. If you haven't been able to find it, the $builder is created here.

Ok, so what about the $options?

Well, we do control these. We can pass three arguments in when calling createForm. Depending on the version of Symfony you are using, this may be in one of two places:

The function signature is the same:

    /**
     * Creates and returns a Form instance from the type of the form.
     *
     * @param string $type    The fully qualified class name of the form type
     * @param mixed  $data    The initial data for the form
     * @param array  $options Options for the form
     *
     * @return Form
     */
    protected function createForm($type, $data = null, array $options = array())
    {
        return $this->container->get('form.factory')->create($type, $data, $options);
    }

The $data and $options arguments are not required - they have a default set, of null and an empty array() respectively.

That third argument - $options - is where we can pass through options to our form, as we shall do shortly. Again for clarity, from our controller, we could write this as:

$form = $this->createForm(ContactFormType::class, null, []);

Same thing. We just don't need the second and third arguments if we aren't using them.

The last potentially confusing part is the use of ContactFormType::class.

This is not something that is specific to Symfony. This is a feature available as of PHP5.5 that will return a string representing the fully qualified class name of the given class. Huh?

Ok, so in days gone by we would pass in a string here. This is potentially error prone. We heavily use namespaces in Symfony-land, so typing the full path to the class could potentially introduce typos or whatnot:

AppBundle\Form\Type\ContactFormType

or, simply ContactFormType::class, which resolves to the very same thing. Less typing, less bugs.

POSTing Elsewhere

With our current implementation we have the somewhat confusing setup of doing two things with one controller action:

<?php

// /src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Form\Type\ContactFormType;

class SupportController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(Request $request)
    {
        $form = $this->createForm(ContactFormType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // do interesting things here
        }

        return $this->render('support/index.html.twig', [
            'our_form' => $form->createView(),
        ]);

In indexAction we createForm, we handle a form submission, and we render out the form page.

That's quite a lot of stuff happening in one controller action. Generally, the more stuff happening, the more confusing things get.

In a previous video we covered how if we don't manually specify the form's action then it will implicitly / by default POST back to the controller action that originally rendered the form. We can override this using the $options array that we covered earlier.

Now, we are going to separate out the GET request for our form, from the POST request which processes the user's form submission. This should - in theory - make our lives easier by separating these two concerns. Fewer things to keep in your head when working with indexAction, right?

Actually, Symfony doesn't make this so easy, but let's go with it, as in my opinion it's the right thing to do - when possible. Your opinion may differ, and that's cool. Feel free to keep things in the controller if it makes your life easier, which sometimes it will.

Let's create a new action for handling form submissions:

<?php

// /src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Form\Type\ContactFormType;

class SupportController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(Request $request)
    {
        $form = $this->createForm(ContactFormType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // do interesting things here
        }

        return $this->render('support/index.html.twig', [
            'our_form' => $form->createView(),
        ]);
    }

    /**
     * @param Request $request
     * @Route("/form-submission", name="handle_form_submission")
     * @Method("POST")
     */
    public function handleFormSubmissionAction(Request $request)
    {

    }

Creating a new controller action is as easy as defining a new public function and setting up any additional annotations we need.

In this case, we want the route to have the path of /form-submission, and the friendly name of handle_form_submission. This name will make our lives easier during development, allowing us to refer to this route by name rather than by a hardcoded URI. If we change that URI for any reason then we don't need to update a bunch of places in our code.

We also specify @Method("POST"). This is a new annotation so remember the associated use statement:

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;

What we're doing here is ensuring that regular end users can't hit this route directly via a plain old GET request, or typical web browser browse to /form-submission.

We also inject the $request object as we will be working with that shortly.

Now that we have our handleFormSubmissionAction we can extract all of the form processing logic from the indexAction, and move it into this new action instead:

<?php

// /src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Form\Type\ContactFormType;

class SupportController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(Request $request)
    {
        $form = $this->createForm(ContactFormType::class);

        return $this->render('support/index.html.twig', [
            'our_form' => $form->createView(),
        ]);
    }

    /**
     * @param Request $request
     * @Route("/form-submission", name="handle_form_submission")
     * @Method("POST")
     */
    public function handleFormSubmissionAction(Request $request)
    {
        $form = $this->createForm(ContactFormType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // do interesting things here
        }
    }

Now, the downside here is that we must still declare:

$form = $this->createForm(ContactFormType::class);

In both controller actions. That's not so bad, as we are re-using the call to ContactFormType rather than manually setting up the form in both controller actions. Already we can see some benefit to this.

We aren't finished here just yet, though we will continue on with this in the very next video.

Code For This Course

Get the code for this course.

Episodes