Skip to content

Feedback ontvangen via formulieren

Tijd om onze deelnemers feedback te laten geven op conferenties. Ze zullen hun reactie indienen via een HTML-formulier.

Form types genereren

Gebruik de Maker bundle om een form class te genereren:

1
$ symfony console make:form CommentFormType Comment
1
2
3
4
5
6
7
8
created: src/Form/CommentFormType.php


 Success!


Next: Add fields to your form and start using it.
Find the documentation at https://symfony.com/doc/current/forms.html

De App\Form\CommentFormType class definieert een form voor de App\Entity\Comment entity:

src/App/Form/CommentFormType.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
namespace App\Form;

use App\Entity\Comment;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CommentFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('author')
            ->add('text')
            ->add('email')
            ->add('createdAt')
            ->add('photoFilename')
            ->add('conference')
        ;
    }

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

Een form type beschrijft de formuliervelden die aan een model gebonden zijn. Het voert dataconversie uit tussen de ingediende gegevens en de properties van de model class. Standaard gebruikt Symfony metadata - zoals de Doctrine metadata - van de Comment entity om de configuratie van elk veld te raden. Het text veld wordt bijvoorbeeld weergegeven als een veld textarea omdat het een grotere kolom in de database gebruikt.

Een formulier weergeven

Om een formulier weer te geven maak je het aan in de controller en geeft je het door aan de template:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -2,7 +2,9 @@

 namespace App\Controller;

+use App\Entity\Comment;
 use App\Entity\Conference;
+use App\Form\CommentFormType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -31,6 +33,9 @@ class ConferenceController extends AbstractController
     #[Route('/conference/{slug}', name: 'conference')]
     public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
     {
+        $comment = new Comment();
+        $form = $this->createForm(CommentFormType::class, $comment);
+
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

@@ -39,6 +44,7 @@ class ConferenceController extends AbstractController
             'comments' => $paginator,
             'previous' => $offset - CommentRepository::PAGINATOR_PER_PAGE,
             'next' => min(count($paginator), $offset + CommentRepository::PAGINATOR_PER_PAGE),
+            'comment_form' => $form->createView(),
         ]));
     }
 }

Je moet nooit direct het formuliertype instantiëren. Gebruik in plaats daarvan de createForm() methode. Deze methode maakt deel uit van AbstractController en vergemakkelijkt het maken van formulieren.

Wanneer je een formulier doorgeeft aan een template, gebruik dan createView() om de gegevens te converteren naar een formaat dat geschikt is voor templates.

Het formulier weergeven in een template kan je met de form Twig functie:

1
2
3
4
5
6
7
8
9
10
11
--- a/templates/conference/show.html.twig
+++ b/templates/conference/show.html.twig
@@ -30,4 +30,8 @@
     {% else %}
         <div>No comments have been posted yet for this conference.</div>
     {% endif %}
+
+    <h2>Add your own feedback</h2>
+
+    {{ form(comment_form) }}
 {% endblock %}

Bij het vernieuwen van een conferentiepagina in de browser, merk je op dat elk formulierveld de juiste HTML-widget toont (het gegevenstype is afgeleid van het model):

/conference/amsterdam-2019

De form() functie genereert het HTML-formulier op basis van alle informatie die in het formuliertype gedefinieerd is. Het voegt ook enctype=multipart/form-data toe aan de <form> tag, zoals dat nodig is voor het invoerveld voor het uploaden van bestanden. Bovendien zorgt de functie er voor dat er foutmeldingen worden weergegeven wanneer de inzending foute gegevens bevat. Alles kan worden aangepast door de standaard templates te overschrijven, maar dat hebben we voor dit project niet nodig.

Een Form Type aanpassen

Zelfs als formuliervelden worden geconfigureerd op basis van hun model-class, kan je de standaard configuratie in de Form type class rechtstreeks aanpassen:

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
--- a/src/Form/CommentFormType.php
+++ b/src/Form/CommentFormType.php
@@ -4,20 +4,31 @@ namespace App\Form;

 use App\Entity\Comment;
 use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\EmailType;
+use Symfony\Component\Form\Extension\Core\Type\FileType;
+use Symfony\Component\Form\Extension\Core\Type\SubmitType;
 use Symfony\Component\Form\FormBuilderInterface;
 use Symfony\Component\OptionsResolver\OptionsResolver;
+use Symfony\Component\Validator\Constraints\Image;

 class CommentFormType extends AbstractType
 {
     public function buildForm(FormBuilderInterface $builder, array $options): void
     {
         $builder
-            ->add('author')
+            ->add('author', null, [
+                'label' => 'Your name',
+            ])
             ->add('text')
-            ->add('email')
-            ->add('createdAt')
-            ->add('photoFilename')
-            ->add('conference')
+            ->add('email', EmailType::class)
+            ->add('photo', FileType::class, [
+                'required' => false,
+                'mapped' => false,
+                'constraints' => [
+                    new Image(['maxSize' => '1024k'])
+                ],
+            ])
+            ->add('submit', SubmitType::class)
         ;
     }

Merk op dat we een submit knop hebben toegevoegd (maar we nog steeds de {{ form(comment_form) }} aanroep kunnen blijven gebruiken in de template).

Sommige velden kunnen niet automatisch worden geconfigureerd, zoals het photoFilename veld. De Comment entity hoeft alleen de bestandsnaam van de foto op te slaan, maar het formulier moet wel het uploaden van het bestand afhandelen. Om dit op te lossen hebben we een veld photo toegevoegd als niet-gemapped veld. Het wordt niet toegevoegd als property op de Comment entity. We zullen het handmatig instellen om specifieke logica te implementeren (zoals het opslaan van de geüploade foto op de schijf).

Om een voorbeeld te geven van aanpassingen hebben we ook het standaardlabel van enkele velden aangepast.

/conference/amsterdam-2019

Modellen valideren

Het Form Type configureert de frontend rendering van het formulier (via HTML5 validatie). Hier is het gegenereerde HTML-formulier:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<form name="comment_form" method="post" enctype="multipart/form-data">
    <div id="comment_form">
        <div >
            <label for="comment_form_author" class="required">Your name</label>
            <input type="text" id="comment_form_author" name="comment_form[author]" required="required" maxlength="255" />
        </div>
        <div >
            <label for="comment_form_text" class="required">Text</label>
            <textarea id="comment_form_text" name="comment_form[text]" required="required"></textarea>
        </div>
        <div >
            <label for="comment_form_email" class="required">Email</label>
            <input type="email" id="comment_form_email" name="comment_form[email]" required="required" />
        </div>
        <div >
            <label for="comment_form_photo">Photo</label>
            <input type="file" id="comment_form_photo" name="comment_form[photo]" />
        </div>
        <div >
            <button type="submit" id="comment_form_submit" name="comment_form[submit]">Submit</button>
        </div>
        <input type="hidden" id="comment_form__token" name="comment_form[_token]" value="DwqsEanxc48jofxsqbGBVLQBqlVJ_Tg4u9-BL1Hjgac" />
    </div>
</form>

Het formulier maakt gebruik van de email invoer voor de reactie e-mail, en maakt het grootste deel van de velden required. Merk op dat het formulier ook een verborgen _token veld bevat om het formulier te beschermen tegen CSRF-aanvallen.

Maar als het indienen van het formulier de HTML-validatie omzeilt (door gebruik te maken van een HTTP client die deze validatieregels niet afdwingt, zoals cURL), kunnen ongeldige gegevens op de server terecht komen.

We moeten ook een aantal validaties toevoegen aan het Comment datamodel:

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
--- a/src/Entity/Comment.php
+++ b/src/Entity/Comment.php
@@ -4,6 +4,7 @@ namespace App\Entity;

 use App\Repository\CommentRepository;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;

 #[ORM\Entity(repositoryClass: CommentRepository::class)]
 #[ORM\HasLifecycleCallbacks]
@@ -15,12 +16,16 @@ class Comment
     private $id;

     #[ORM\Column(type: 'string', length: 255)]
+    #[Assert\NotBlank]
     private $author;

     #[ORM\Column(type: 'text')]
+    #[Assert\NotBlank]
     private $text;

     #[ORM\Column(type: 'string', length: 255)]
+    #[Assert\NotBlank]
+    #[Assert\Email]
     private $email;

     #[ORM\Column(type: 'datetime_immutable')]

Formulieren afhandelen

We hebben voldoende code geschreven om het formulier weer te geven.

We moeten het indienen van het formulier en het opslaan in de database afhandelen in de controller:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
 use App\Form\CommentFormType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
+use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -16,10 +17,12 @@ use Twig\Environment;
 class ConferenceController extends AbstractController
 {
     private $twig;
+    private $entityManager;

-    public function __construct(Environment $twig)
+    public function __construct(Environment $twig, EntityManagerInterface $entityManager)
     {
         $this->twig = $twig;
+        $this->entityManager = $entityManager;
     }

     #[Route('/', name: 'homepage')]
@@ -35,6 +38,15 @@ class ConferenceController extends AbstractController
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
+        $form->handleRequest($request);
+        if ($form->isSubmitted() && $form->isValid()) {
+            $comment->setConference($conference);
+
+            $this->entityManager->persist($comment);
+            $this->entityManager->flush();
+
+            return $this->redirectToRoute('conference', ['slug' => $conference->getSlug()]);
+        }

         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

Wanneer het formulier wordt ingediend, wordt het Comment-object bijgewerkt aan de hand van de ingediende gegevens.

De conferentie moet dezelfde zijn als die van de URL (we hebben deze uit het formulier verwijderd).

Als het formulier niet geldig is, tonen we de pagina, maar het formulier zal nu de ingediende waarden en foutmeldingen bevatten, zodat deze aan de gebruiker kunnen worden getoond.

Test het formulier. Het zou moeten werken en de gegevens zouden opgeslagen moeten zijn in de database (controleer dit in de admin backend). Er is echter één probleem: foto's. Deze werken niet omdat we ze nog niet in de controller afhandelen.

Bestanden uploaden

Geüploade foto's moeten opgeslagen worden op de lokale schijf, op een plaats die toegankelijk is via de frontend, zodat we ze kunnen weergeven op de conferentie-pagina. We gebruiken de map public/uploads/photos hiervoor:

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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -9,6 +9,7 @@ use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Doctrine\ORM\EntityManagerInterface;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\File\Exception\FileException;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Annotation\Route;
@@ -34,13 +35,22 @@ class ConferenceController extends AbstractController
     }

     #[Route('/conference/{slug}', name: 'conference')]
-    public function show(Request $request, Conference $conference, CommentRepository $commentRepository): Response
+    public function show(Request $request, Conference $conference, CommentRepository $commentRepository, string $photoDir): Response
     {
         $comment = new Comment();
         $form = $this->createForm(CommentFormType::class, $comment);
         $form->handleRequest($request);
         if ($form->isSubmitted() && $form->isValid()) {
             $comment->setConference($conference);
+            if ($photo = $form['photo']->getData()) {
+                $filename = bin2hex(random_bytes(6)).'.'.$photo->guessExtension();
+                try {
+                    $photo->move($photoDir, $filename);
+                } catch (FileException $e) {
+                    // unable to upload the photo, give up
+                }
+                $comment->setPhotoFilename($filename);
+            }

             $this->entityManager->persist($comment);
             $this->entityManager->flush();

Bij het afhandelen van foto-uploads maken we een willekeurige naam voor het bestand. Vervolgens verplaatsen we het geüploade bestand naar de uiteindelijke locatie (de foto map). Tenslotte slaan we de bestandsnaam op in het Comment-object.

Zie je het nieuwe argument op de show() methode? $photoDir is een string en geen service. Hoe kan Symfony weten wat het hier moet injecteren? De Symfony Container kan naast services ook parameters bevatten. Parameters hebben een scalaire vorm en helpen je bij het configureren van services. Deze parameters kunnen expliciet in de services worden geïnjecteerd, of ze kunnen gebonden worden via hun naam:

1
2
3
4
5
6
7
8
9
10
11
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -10,6 +10,8 @@ services:
     _defaults:
         autowire: true      # Automatically injects dependencies in your services.
         autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
+        bind:
+            string $photoDir: "%kernel.project_dir%/public/uploads/photos"

     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

Via de bind instelling kan je Symfony de juiste waarde laten injecteren wanneer een service als argument $photoDir heeft.

Probeer een PDF-bestand te uploaden in plaats van een foto. Je zal een foutmeldingen te zien krijgen. Het design is op dit moment nogal lelijk, maar maak je geen zorgen, alles zal in de volgende stappen mooier worden, wanneer we aan het design van de website gaan werken. Om styling te geven aan alle form elements zullen we één regel configuratie wijzigen.

Formulieren debuggen

Wanneer een formulier wordt ingediend en iets werkt niet goed, gebruik dan het "Form" scherm van de Symfony Profiler. Het geeft je informatie over het formulier, alle opties, de ingediende gegevens en hoe deze intern worden omgezet. Als het formulier fouten bevat, worden deze ook vermeld.

De typische formulier-workflow gaat als volgt:

  • Het formulier wordt weergegeven op een pagina;
  • De gebruiker dient het formulier in via een POST request;
  • De server leidt de gebruiker om naar een andere pagina of naar dezelfde pagina.

Maar hoe kun je toegang krijgen tot de profiler bij een succesvol request? Omdat de pagina onmiddellijk wordt omgeleid, krijgen we de web debug toolbar voor het POST-request nooit te zien. Geen probleem: beweeg je muis over het linker, groene "200" gedeelte op de omgeleide pagina. Je zou hier de "302" omleiding moeten zien met een link naar het profile (tussen haakjes).

/conference/amsterdam-2019

Klik erop om toegang te krijgen tot het POST-requestprofile en ga dan naar het "Form" scherm:

1
$ rm -rf var/cache
/_profiler/450aa5

Geüploade foto's weergeven in de admin backend

De admin backend toont op dit moment de bestandsnaam van de foto, maar we willen de daadwerkelijke foto zien:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
--- a/src/Controller/Admin/CommentCrudController.php
+++ b/src/Controller/Admin/CommentCrudController.php
@@ -9,6 +9,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
 use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
 use EasyCorp\Bundle\EasyAdminBundle\Filter\EntityFilter;
@@ -45,7 +46,9 @@ class CommentCrudController extends AbstractCrudController
         yield TextareaField::new('text')
             ->hideOnIndex()
         ;
-        yield TextField::new('photoFilename')
+        yield ImageField::new('photoFilename')
+            ->setBasePath('/uploads/photos')
+            ->setLabel('Photo')
             ->onlyOnIndex()
         ;

Geüploade foto's niet in Git opnemen

Commit dit nog niet! We willen namelijk geen geüploade afbeeldingen opslaan in de Git repository. Voeg de /public/uploads map toe aan het .gitignore bestand:

1
2
3
4
5
6
7
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+/public/uploads

 ###> symfony/framework-bundle ###
 /.env.local

Het opslaan van geüploade bestanden op productieservers

De laatste stap is het opslaan van de geüploade bestanden op productieservers. Maar waarom moeten we hier iets speciaals doen? Moderne cloud-platforms maken vaak gebruik van alleen-lezen containers en Platform.sh is geen uitzondering op deze regel.

Niet alles is alleen-lezen in een Symfony project. Symfony doet zijn best om zoveel mogelijk cache te genereren bij het opbouwen van de container (tijdens de cache warmup fase), maar Symfony moet nog steeds de gebruikerscache kunnen schrijven, de logs, de sessies (als ze op het filesystem worden opgeslagen) en meer.

Bekijk .platform.app.yaml, er is al een schrijfbare mount voor de var/ map. De var/ map is de enige map waar Symfony schrijft (caches, logs, ....).

We maken een nieuwe mount voor geüploade foto's:

1
2
3
4
5
6
7
8
9
10
--- a/.platform.app.yaml
+++ b/.platform.app.yaml
@@ -35,6 +35,7 @@ web:

 mounts:
     "/var": { source: local, source_path: var }
+    "/public/uploads": { source: local, source_path: uploads }

 relationships:
     database: "database:postgresql"

Je kan nu de code deployen. De foto's zullen opgeslagen worden in de public/uploads/ map zoals in onze lokale versie.

This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version