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:
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:
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.
Create a new Action in src/Controller/MovieController.php
to display and process the Form.
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.
Finally, link the new page from the footer in templates/base.html.twig
.
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.