Skip to content
  • About
    • What is Symfony?
    • Community
    • News
    • Contributing
    • Support
  • Documentation
    • Symfony Docs
    • Symfony Book
    • Screencasts
    • Symfony Bundles
    • Symfony Cloud
    • Training
  • Services
    • Platform.sh for Symfony Best platform to deploy Symfony apps
    • SymfonyInsight Automatic quality checks for your apps
    • Symfony Certification Prove your knowledge and boost your career
    • SensioLabs Professional services to help you with Symfony
    • Blackfire Profile and monitor performance of your apps
  • Other
  • Blog
  • Download
sponsored by
  1. Home
  2. Documentation
  3. Symfony: The Fast Track
  4. English
  5. Accepting Feedback with Forms

Accepting Feedback with Forms

Time to let our attendees give feedback on conferences. They will contribute their comments through an HTML form.

Generating a Form Type

Use the Maker bundle to generate a form class:

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


 Success!


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

The App\Form\CommentType class defines a form for the App\Entity\Comment entity:

src/Form/CommentType.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 CommentType 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,
        ]);
    }
}

A form type describes the form fields bound to a model. It does the data conversion between submitted data and the model class properties. By default, Symfony uses metadata from the Comment entity - such as the Doctrine metadata - to guess configuration about each field. For example, the text field renders as a textarea because it uses a larger column in the database.

Displaying a Form

To display the form to the user, create the form in the controller and pass it to the 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\CommentType;
 use App\Repository\CommentRepository;
 use App\Repository\ConferenceRepository;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -23,6 +25,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(CommentType::class, $comment);
+
         $offset = max(0, $request->query->getInt('offset', 0));
         $paginator = $commentRepository->getCommentPaginator($conference, $offset);

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

You should never instantiate the form type directly. Instead, use the createForm() method. This method is part of AbstractController and eases the creation of forms.

Displaying the form in the template can be done via the form Twig function:

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 %}

When refreshing a conference page in the browser, note that each form field shows the right HTML widget (the data type is derived from the model):

/conference/amsterdam-2019

The form() function generates the HTML form based on all the information defined in the Form type. It also adds enctype=multipart/form-data on the <form> tag as required by the file upload input field. Moreover, it takes care of displaying error messages when the submission has some errors. Everything can be customized by overriding the default templates, but we won't need it for this project.

Customizing a Form Type

Even if form fields are configured based on their model counterpart, you can customize the default configuration in the form type class directly:

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
--- a/src/Form/CommentType.php
+++ b/src/Form/CommentType.php
@@ -6,26 +6,32 @@ use App\Entity\Comment;
 use App\Entity\Conference;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 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 CommentType extends AbstractType
 {
     public function buildForm(FormBuilderInterface $builder, array $options): void
     {
         $builder
-            ->add('author')
-            ->add('text')
-            ->add('email')
-            ->add('createdAt', null, [
-                'widget' => 'single_text',
+            ->add('author', null, [
+                'label' => 'Your name',
             ])
-            ->add('photoFilename')
-            ->add('conference', EntityType::class, [
-                'class' => Conference::class,
-                'choice_label' => 'id',
+            ->add('text')
+            ->add('email', EmailType::class)
+            ->add('photo', FileType::class, [
+                'required' => false,
+                'mapped' => false,
+                'constraints' => [
+                    new Image(['maxSize' => '1024k'])
+                ],
             ])
-        ;
+            ->add('submit', SubmitType::class)
+       ;
     }

     public function configureOptions(OptionsResolver $resolver): void

Note that we have added a submit button (that allows us to keep using the simple {{ form(comment_form) }} expression in the template).

Some fields cannot be auto-configured, like the photoFilename one. The Comment entity only needs to save the photo filename, but the form has to deal with the file upload itself. To handle this case, we have added a field called photo as un-mapped field: it won't be mapped to any property on Comment. We will manage it manually to implement some specific logic (like storing the uploaded photo on disk).

As an example of customization, we have also modified the default label for some fields.

/conference/amsterdam-2019

Validating Models

The Form Type configures the frontend rendering of the form (via some HTML5 validation). Here is the generated HTML form:

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>

The form uses the email input for the comment email and makes most of the fields required. Note that the form also contains a _token hidden field to protect the form from CSRF attacks.

But if the form submission bypasses the HTML validation (by using an HTTP client that does not enforce these validation rules like cURL), invalid data can hit the server.

We also need to add some validation constraints on the Comment data model:

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
@@ -5,6 +5,7 @@ namespace App\Entity;
 use App\Repository\CommentRepository;
 use Doctrine\DBAL\Types\Types;
 use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Validator\Constraints as Assert;

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

     #[ORM\Column(length: 255)]
+    #[Assert\NotBlank]
     private ?string $author = null;

     #[ORM\Column(type: Types::TEXT)]
+    #[Assert\NotBlank]
     private ?string $text = null;

     #[ORM\Column(length: 255)]
+    #[Assert\NotBlank]
+    #[Assert\Email]
     private ?string $email = null;

     #[ORM\Column]

Handling a Form

The code we have written so far is enough to display the form.

We should now handle the form submission and the persistence of its information to the database in the 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
--- a/src/Controller/ConferenceController.php
+++ b/src/Controller/ConferenceController.php
@@ -7,6 +7,7 @@ use App\Entity\Conference;
 use App\Form\CommentType;
 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;
@@ -14,6 +15,11 @@ use Symfony\Component\Routing\Attribute\Route;

 class ConferenceController extends AbstractController
 {
+    public function __construct(
+        private EntityManagerInterface $entityManager,
+    ) {
+    }
+
     #[Route('/', name: 'homepage')]
     public function index(ConferenceRepository $conferenceRepository): Response
     {
@@ -27,6 +33,15 @@ class ConferenceController extends AbstractController
     {
         $comment = new Comment();
         $form = $this->createForm(CommentType::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);

When the form is submitted, the Comment object is updated according to the submitted data.

The conference is forced to be the same as the one from the URL (we removed it from the form).

If the form is not valid, we display the page, but the form will now contain submitted values and error messages so that they can be displayed back to the user.

Try the form. It should work well and the data should be stored in the database (check it in the admin backend). There is one problem though: photos. They do not work as we have not handled them yet in the controller.

Uploading Files

Uploaded photos should be stored on the local disk, somewhere accessible by the frontend so that we can display them on the conference page. We will store them under the public/uploads/photos directory.

As we don't want to hardcode the directory path in the code, we need a way to store it globally in the configuration. The Symfony Container is able to store parameters in addition to services, which are scalars that help configure services:

1
2
3
4
5
6
7
8
9
10
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -4,6 +4,7 @@
 # Put parameters here that don't need to change on each machine where the app is deployed
 # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
 parameters:
+    photo_dir: "%kernel.project_dir%/public/uploads/photos"

 services:
     # default configuration for services in *this* file

We have already seen how services are automatically injected into constructor arguments. For container parameters, we can explicitly inject them via the Autowire attribute.

Now, we have everything we need to know to implement the logic needed to store the uploaded file to its final destination:

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
--- 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\DependencyInjection\Attribute\Autowire;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\Routing\Attribute\Route;
@@ -29,13 +30,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,
+        #[Autowire('%photo_dir%')] string $photoDir,
+    ): Response {
         $comment = new Comment();
         $form = $this->createForm(CommentType::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();
+                $photo->move($photoDir, $filename);
+                $comment->setPhotoFilename($filename);
+            }

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

To manage photo uploads, we create a random name for the file. Then, we move the uploaded file to its final location (the photo directory). Finally, we store the filename in the Comment object.

Try to upload a PDF file instead of a photo. You should see the error messages in action. The design is quite ugly at the moment, but don't worry, everything will turn beautiful in a few steps when we will work on the design of the website. For the forms, we will change one line of configuration to style all form elements.

Debugging Forms

When a form is submitted and something does not work quite well, use the "Form" panel of the Symfony Profiler. It gives you information about the form, all its options, the submitted data and how they are converted internally. If the form contains any errors, they will be listed as well.

The typical form workflow goes like this:

  • The form is displayed on a page;
  • The user submits the form via a POST request;
  • The server redirects the user to another page or the same page.

But how can you access the profiler for a successful submit request? Because the page is immediately redirected, we never see the web debug toolbar for the POST request. No problem: on the redirected page, hover over the left "200" green part. You should see the "302" redirection with a link to the profile (in parenthesis).

/conference/amsterdam-2019

Click on it to access the POST request profile, and go to the "Form" panel:

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

Displaying Uploaded Photos in the Admin Backend

The admin backend is currently displaying the photo filename, but we want to see the actual photo:

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
@@ -10,6 +10,7 @@ use EasyCorp\Bundle\EasyAdminBundle\Field\AssociationField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\EmailField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
+use EasyCorp\Bundle\EasyAdminBundle\Field\ImageField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextareaField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextEditorField;
 use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
@@ -47,7 +48,9 @@ class CommentCrudController extends AbstractCrudController
         yield TextareaField::new('text')
             ->hideOnIndex()
         ;
-        yield TextField::new('photoFilename')
+        yield ImageField::new('photoFilename')
+            ->setBasePath('/uploads/photos')
+            ->setLabel('Photo')
             ->onlyOnIndex()
         ;

Excluding Uploaded Photos from Git

Don't commit yet! We don't want to store uploaded images in the Git repository. Add the /public/uploads directory to the .gitignore file:

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

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

Storing Uploaded Files on Production Servers

The last step is to store the uploaded files on production servers. Why would we have to do something special? Because most modern cloud platforms use read-only containers for various reasons. Platform.sh is no exception.

Not everything is read-only in a Symfony project. We try hard to generate as much cache as possible when building the container (during the cache warmup phase), but Symfony still needs to be able to write somewhere for the user cache, the logs, the sessions if they are stored on the filesystem, and more.

Have a look at .platform.app.yaml, there is already a writeable mount for the var/ directory. The var/ directory is the only directory where Symfony writes (caches, logs, ...).

Let's create a new mount for uploaded photos:

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:

You can now deploy the code and photos will be stored in the public/uploads/ directory like our local version.

Going Further

  • SymfonyCasts Forms tutorial;
  • How to customize Symfony Form rendering in HTML;
  • Validating Symfony Forms;
  • The Symfony Form Types reference;
  • The FlysystemBundle docs, which provides integration with multiple cloud storage providers, such as AWS S3, Azure and Google Cloud Storage;
  • The Symfony Configuration Parameters.
  • The Symfony Validation Constraints;
  • The Symfony Form Cheat Sheet.
Previous page Managing the Lifecycle of Doctrine Objects
Next page Securing the Admin Backend
This work, including the code samples, is licensed under a Creative Commons BY-NC-SA 4.0 license.
TOC
    Version

    Symfony 6.4 is backed by

    Peruse our complete Symfony & PHP solutions catalog for your web development needs.

    Peruse our complete Symfony & PHP solutions catalog for your web development needs.

    Save your teams and projects before they sink

    Save your teams and projects before they sink

    Version:
    Locale:
    ebook

    This book is backed by:

    see all backers

    Symfony footer

    Avatar of Alexandru Nastase, a Symfony contributor

    Thanks Alexandru Nastase for being a Symfony contributor

    2 commits • 86 lines changed

    View all contributors that help us make Symfony

    Become a Symfony contributor

    Be an active part of the community and contribute ideas, code and bug fixes. Both experts and newcomers are welcome.

    Learn how to contribute

    Symfony™ is a trademark of Symfony SAS. All rights reserved.

    • What is Symfony?

      • What is Symfony?
      • Symfony at a Glance
      • Symfony Components
      • Symfony Releases
      • Security Policy
      • Logo & Screenshots
      • Trademark & Licenses
      • symfony1 Legacy
    • Learn Symfony

      • Symfony Docs
      • Symfony Book
      • Reference
      • Bundles
      • Best Practices
      • Training
      • eLearning Platform
      • Certification
    • Screencasts

      • Learn Symfony
      • Learn PHP
      • Learn JavaScript
      • Learn Drupal
      • Learn RESTful APIs
    • Community

      • Symfony Community
      • SymfonyConnect
      • Events & Meetups
      • Projects using Symfony
      • Contributors
      • Symfony Jobs
      • Backers
      • Code of Conduct
      • Downloads Stats
      • Support
    • Blog

      • All Blog Posts
      • A Week of Symfony
      • Case Studies
      • Cloud
      • Community
      • Conferences
      • Diversity
      • Living on the edge
      • Releases
      • Security Advisories
      • Symfony Insight
      • Twig
      • SensioLabs Blog
    • Services

      • SensioLabs services
      • Train developers
      • Manage your project quality
      • Improve your project performance
      • Host Symfony projects

      Powered by

    Follow Symfony