Symfony 4 Workshop

Forms and processing input

Theory

Forms need some clarification

So far we are presenting data, but what about letting the user introduce new data?

We can use the Form Component for that.

The Form component makes use of the Validator and Translation Components, so let's install all three:

composer req validator

composer req translation

composer req form

We are going to build the following feature: A user can create a Sale (new Entity), and in the form the user specifies the number of tickets. We will process the form and create new Tickets (new Entity) with a row number and a seat number. Tickets will have a relationship many-to-one with Movie and Sale.

Let's write these new Entities.

Write this code into src/Entity/Sale:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php
/**
 * @license MIT
 */

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity
 * @ORM\Table(name="sale")
 */
class Sale
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $fullName;

    /**
     * @ORM\OneToMany(targetEntity="Ticket", mappedBy="sale")
     */
    private $tickets;

    public function __construct()
    {
        $this->tickets = new ArrayCollection();
    }

    public function __toString()
    {
        return $this->getFullName();
    }

    public function getId()
    {
        return $this->id;
    }

    public function getFullName()
    {
        return $this->fullName;
    }

    public function setFullName(string $fullName)
    {
        $this->fullName = $fullName;

        return $this;
    }

    public function getTickets()
    {
        return $this->tickets;
    }

    public function addTicket(Ticket $ticket)
    {
        $this->tickets[] = $ticket;

        return $this;
    }
}

Write this code into src/Entity/Ticket:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<?php
/**
 * @license MIT
 */
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="ticket")
 */
class Ticket
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Movie", inversedBy="tickets")
     */
    private $movie;

    /**
     * @ORM\ManyToOne(targetEntity="Sale", inversedBy="tickets")
     */
    private $sale;

    /**
     * @ORM\Column(type="smallint")
     */
    private $row;

    /**
     * @ORM\Column(type="smallint")
     */
    private $seat;

    /**
     * @ORM\Column(type="decimal", precision=5, scale=2)
     */
    private $price;

    public function __construct()
    {
        $this->price = 8;
    }

    public function getId()
    {
        return $this->id;
    }

    public function getRow()
    {
        return $this->row;
    }

    public function setRow(int $row)
    {
        $this->row = $row;

        return $this;
    }

    public function getSeat()
    {
        return $this->seat;
    }

    public function setSeat(int $seat)
    {
        $this->seat = $seat;

        return $this;
    }

    public function getPrice()
    {
        return $this->price;
    }

    public function setPrice(int $price)
    {
        $this->price = $price;

        return $this;
    }

    public function getMovie()
    {
        return $this->movie;
    }

    public function setMovie(Movie $movie)
    {
        $this->movie = $movie;
        $movie->addTicket($this);

        return $this;
    }

    public function getSale()
    {
        return $this->sale;
    }

    public function setSale(Sale $sale)
    {
        $this->sale = $sale;
        $sale->addTicket($this);

        return $this;
    }
}

We need also to add the inverse side of the relationship tickets->movies in src/Entity/Movie.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* @ORM\OneToMany(targetEntity="Ticket", mappedBy="movie")
*/
private $tickets;

public function __construct()
{
    $this->actors = new ArrayCollection();
    $this->tickets = new ArrayCollection();
}

public function getTickets()
{
    return $this->tickets;
}

public function addTicket(Ticket $ticket)
{
    $this->tickets[] = $ticket;

    return $this;
}

In many-to-one relationships, the owning side is the many side, as it will hold the foreign key (in this case sale_id and movie_id are the foreign keys of these two relationships).

We are also providing an example on how to set default values. Tickets have a default price of 8. We might want to set it based on a config parameter in the future, but at the moment we are just setting a default value. We can do it by simply assigning its value in the constructor:

1
2
3
4
5
6

public function __construct()
{
    $this->price = 8;
} 
        

When working with Doctrine entities it is important to remember that they are just regular PHP classes with mapping information.

php bin/console doctrine:schema:update --force

Now we can write our first Form in src/Controller/MovieController.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use App\Entity\Sale;

//...

    /**
     * @Route("/movie/{movieId}/book", name="book")
     */
    public function newSale($movieId)
    {
        $sale = new Sale();
        $movie = $this->getDoctrine()
            ->getRepository(Movie::class)
            ->find($movieId);
    
        $form = $this->createFormBuilder($sale)
            ->add('fullName', TextType::class)
            ->add('numTickets', ChoiceType::class, [
                'label' => 'Number of tickets',
                'mapped' => false,
                'choices' => [
                    1 => 1,
                    2 => 2,
                    3 => 3,
                    4 => 4,
                    5 => 5,
                ],
            ])
            ->add('save', SubmitType::class, array('label' => 'Book tickets'))
            ->getForm();
    
        return $this->render('movies/newSale.html.twig', [
            'form' => $form->createView(),
            'movie' => $movie,
        ]);
    }

Pay attention to the part where we create a Form Builder and add Form Types to it. Every type has its configuration options, and you can create your own custom types.

Note that the field numTickets has the option mapped set to false. This means that the form won't try to read or write its value from the database. We will use the value of this field later on, to generate tickets.

It is important to call $form->createView(), that will create a FormView, that we can use in our templates.

Let's create the template of a new Sale in templates/movies/newSale.html.twig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{% extends 'base.html.twig' %}

{% block title %}Book tickets for {{movie.name}}{% endblock %}

{% block body %}
    <div class="movie">
        <div class="header"><img src="{{asset('images/'~movie.picture)}}"></div>
        <div class="movie-details">
            <div class="movie-header">
                <div class="title">{{ movie.name }}</div>
                <div class="year">{{ movie.year }}</div>
            </div>
            <div class="directed">
                <div class="directed-by">
                    Directed by
                </div>
                <div class="director">
                    <p>{{ movie.director }}</p>
                </div>
            </div>
        </div>
        <div class="form-container">
            <div class="form-holder">
                <h1>Book tickets</h1>
                {{ form_start(form) }}
                {{ form_widget(form) }}
                {{ form_end(form) }}
            </div>
        </div>
    </div>
{% endblock %}

The relevant part is:

1
2
3
4
5

{{ form_start(form) }}
{{ form_widget(form) }}
{{ form_end(form) }}
        

Form rendering can be customized in great detail, rendering every widget and error placeholders separatedly, providing themes and writing your own widgets, but for now our form looks good.

The only thing that is left is to link it from the templates/movies/movie.html.twig:

Place this line of code at the end of <div class="movie-details" />

1
2
3

<a class="book button" href="{{path('book', {'movieId': movie.id})}}"/>Book Tickets</a>
        

Browse to the newly created page and check that the form is there.

With this we are displaying the form. What about actually processing the data when the user presses the submit button?

Let's update our controller to process the incoming data:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
 * @Route("/movie/{movieId}/book", name="book")
 */
public function newSale(Request $request, $movieId)
{
    $sale = new Sale();
    $movie = $this->getDoctrine()
        ->getRepository(Movie::class)
        ->find($movieId);

    $form = $this->createFormBuilder($sale)
        ->add('fullName', TextType::class)
        ->add('numTickets', ChoiceType::class, [
            'label' => 'Number of tickets',
            'mapped' => false,
            'choices' => [
                1 => 1,
                2 => 2,
                3 => 3,
                4 => 4,
                5 => 5,
            ],
        ])
        ->add('save', SubmitType::class, array('label' => 'Book tickets'))
        ->getForm();

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $em = $this->getDoctrine()->getManager();
        $em->persist($sale);
        $em->flush();
    
        return $this->redirectToRoute('index');
    }

    return $this->render('movies/newSale.html.twig', [
        'form' => $form->createView(),
        'movie' => $movie,
    ]);
}

We can notice several things here:

First, we are passing as parameter Request $request. When Symfony processes the controller, if it sees that there is such parameter, it will inject the Request object in our Controller. We can use it to retrieve information about the request, such as the body of the POST request.

Then, we are binding the form to the Request in $form->handleRequest($request);. At this point, the form contains information about:

  • Wether it has been submitted.
  • If so, if the data is valid.
  • If it is not valid it will add the errors to the form.

With this addition, our form works. Try it out.

Not the best user experience, right? It does not provide any clue to the visitor on whether their booking has been processed or not. We should fix that.

We can add a "flash message" that goes into the session, so after the redirect we can display an alert.

Edit the part of the controller where we process the form so it looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

if ($form->isSubmitted() && $form->isValid()) {
    $em = $this->getDoctrine()->getManager();
    $em->persist($sale);
    $em->flush();

    $this->addFlash(
        'notice',
        'Thank you for your sale!'
    );

    return $this->redirectToRoute('index');
}
        

And then render the flass messages. Edit this in templates/base.html.twig so our main container looks like:

1
2
3
4
5
6
7
8
9
10

<div class="container">
    {% for flash_message in app.session.flashBag.get('notice') %}
        <div class="flash-notice">
            {{ flash_message }}
        </div>
    {% endfor %}
    {% block body %}{% endblock %}
</div>
        

Try the form again, and see the flash message working. That's better.

But we can still do better. Creating forms this way, directly in our components, is not always the best solution, especially with complex forms. It is good to develop the habit of cleaning our controllers and move pieces of code to classes or methods that do only one thing.

Let's create the form in its own class. Create the directory src/Form and place this code in src/Form/SaleType.php.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;

use App\Entity\Sale;

class SaleType extends AbstractType
{

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('fullName', TextType::class)
            ->add('numTickets', ChoiceType::class, [
                'label' => 'Number of tickets',
                'mapped' => false,
                'choices' => [
                    1 => 1,
                    2 => 2,
                    3 => 3,
                    4 => 4,
                    5 => 5,
                ],
            ])
            ->add('save', SubmitType::class, ['label' => 'Book tickets'])
            ->getForm();
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => Sale::class,
        ));
    }
}

The general idea is the same of what we were doing in the controller. We have set a method configureOptions that sets the option data_class, so the Form component will know what kind of objects are tied to this form type. This is in many cases optional, but in general is a good practice.

Now, we can clean our controller:

Remove all the use statements to import types such as TextType and the others.

Instead, import our new and shiny SaleType:

1
2
3

use App\Form\SaleType;
        

And replace the part where we build our form in the Controller with this line:

1
2
3
4
5

//...
$form = $this->createForm(SaleType::class, $sale);
//...
            

That's it! Our controller looks much better now. Let's try to apply what we have just learned.

We would like now to provide the users with a form so they can send us questions. You are going to do this an exercise. But first, let's create a new Entity in src/Entity/Enquiry.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity
 * @ORM\Table(name="enquiry")
 */
class Enquiry
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $fullName;

    /**
     * @ORM\Column(type="string")
     */
    private $email;

    /**
     * @ORM\Column(type="text")
     */
    private $question;

    public function getId()
    {
        return $this->id;
    }

    public function getFullName()
    {
        return $this->fullName;
    }

    public function setFullName(string $fullName)
    {
        $this->fullName = $fullName;

        return $this;
    }

    public function getEmail()
    {
        return $this->email;
    }

    public function setEmail(string $email)
    {
        $this->email = $email;

        return $this;
    }

    public function getQuestion()
    {
        return $this->question;
    }

    public function setQuestion(string $question)
    {
        $this->question = $question;

        return $this;
    }
}

As always, update the schema of the database to match our code:

php bin/console doctrine:schema:update --force

Now, do the following exercises in order in order to build a new page with a form for enquiries:

Exercise

Write a new type in src/Form/EnquiryType.php. It should have the following fields:

  • fullName.
  • email.
  • question (users should have some space to write their question).
  • save (this is the submit button).

Choose the types that better suit each one of these fields from the documentation.

Reveal the solution
Exercise

Create a new Action in src/Controller/MovieController.php to display and process the Form.

Reveal the solution
Exercise

Create a new template in templates/newEnquiry.html.twig with the Form. Don't worry about css classes or styles. The important part is to render the form.

Reveal the solution
Exercise

Finally, link the new page from the footer in templates/base.html.twig.

Reveal the solution

Run the command to update the schema and check how does your new form look.

With this we are done... except for one more detail. It is important to protect our forms against CSRF attacks. Doing this is very easy, actually.

Run this command to install the security components that allow for it:

composer req securitycsrf

And uncomment this line in config/packages/framework.yml (be careful to preserve the indentation):

1
2
3

    csrf_protection: ~
            

Now, if we inspect the source code of the pages that have forms, we will see a hidden field that looks like this:

1
2
3

<input type="hidden" id="enquiry__token" name="enquiry[_token]" value="OgKOAXCcWdyHUul8uRIh2ADQzxykeZX3vkdMwBLj4qc">
            

This is a way to ensure that the user that is submitting the form is not doing this because another page that they are visiting told their browsers in a malicious script to submit a POST request to our site.