Symfony 4 Forms: Build, Render & Conquer!


Handling the Form Submit

Creating the form class and rendering was... easy! Now it's time to talk about handling the form submit. Notice: we haven't configured anything on our form about what URL it should submit to. When we rendered it, we used form_start() and... that's it! Inspect element on the form. By default, form_start() creates a form tag with no action attribute. And when a form tag has no action=, it means that it will submit right back to this same URL.

That's the most common way of handling forms in Symfony: the same controller is responsible for both rendering the form on a GET request and handling the form submit on a POST request. The way you do this always follows a similar pattern.

The Form Submit Logic

First, get the $request object by type-hinting Request - the one from HttpFoundation. Next, add $form->handleRequest($request) and then if ($form->isSubmitted() && $form->isValid()). Inside the if, dd($form->getData().

... lines 1 - 13
class ArticleAdminController extends AbstractController
... lines 16 - 19
public function new(EntityManagerInterface $em, Request $request)
... lines 22 - 23
if ($form->isSubmitted() && $form->isValid()) {
... lines 28 - 31
... lines 33 - 41

Okay, so... this requires a little bit of explanation. First, yea, the $form->handleRequest() makes it look like the submitted data is being read and processed on every request, even the initial GET request that renders the form. But, that's not true! By default, handleRequest() only processes the data when this is a POST request. So, when the form is being submitted. When the form is originally loaded, handleRequest() sees that this is a GET request, does nothing, $form->isSubmitted() returns false, and then the un-submitted form is rendered by Twig.

But, when we POST the form, ah, that's when handleRequest() does its magic. Because the form knows all of its fields, it grabs all of the submitted data from the $request automatically and isSubmitted() returns true. Oh, and later, we'll talk about adding validation to our form. As you can guess, when validation fails, $form->isValid() returns false.

So, wow! This controller does a lot, with very little code. And there are three possible flows. One: if this is a GET request, isSubmitted() returns false and so the form is passed to Twig. Two, if this is a POST request but validation fails, isValid() returns false and so the form is again passed to Twig, but now it will render with errors. We'll see that later. And three: if this is a POST request and validation passes, both isSubmitted() and isValid() are true, and we finally get into the if block. $form->getData() is how we access the final, normalized data that was submitted.

Phew! So, let's try it! Find your browser and create a very important article about the booming tourism industry on Mercury. Submit!

Yes! It dumps out exactly what we probably expected: an array with title and content keys. It's not too fancy yet, but it works nicely.

Saving the Form Data

To insert a new article into the database, we need to use this data to create an Article object. There is a super cool way to do this automatically with the form system. But, to start, let's do it the manual way. Add $data = $form->getData(). Then, create that object: $article = new Article(), $article->setTitle($data['title']);, $article->setContent($data['content']), and the author field is also required. How about, $article->setAuthor() with $this->getUser(): the current user will be the author.

... lines 1 - 19
public function new(EntityManagerInterface $em, Request $request)
... lines 22 - 24
if ($form->isSubmitted() && $form->isValid()) {
$data = $form->getData();
$article = new Article();
... lines 31 - 35
... lines 37 - 40
... lines 42 - 52

To save this to the database, we need the entity manager. And, hey! We already have it thanks to our EntityManagerInterface argument. Save with the normal $em->persist($article), $em->flush().

Awesome! The last thing we always do after a successful form submit is redirect to another page. Let's use return this->redirectToRoute('app_homepage').

... lines 1 - 24
if ($form->isSubmitted() && $form->isValid()) {
... lines 26 - 31
return $this->redirectToRoute('app_homepage');
... lines 37 - 52

Time to test this puppy out! Refresh to re-post the data. Cool! I... think it worked? Scroll down... Hmm. I don't see my article. Ah! But that's because only published articles are shown on the homepage.

Adding an Article List Page

What we really need is a way to see all of the articles in an admin area. We have a "new" article page and a work-in-progress edit page. Now, create a new method: public function list(). Above it, add the annotation @Route("/admin/article"). To fetch all of the articles, add an argument: ArticleRepository $articleRepo, and then say $articles = $articleRepo->findAll(). At the bottom, render a template - article_admin/list.html.twig- and pass this an articles variable.

... lines 1 - 52
* @Route("/admin/article")
public function list(ArticleRepository $articleRepo)
$articles = $articleRepo->findAll();
return $this->render('article_admin/list.html.twig', [
'articles' => $articles,
... lines 64 - 65

Oh, and I'll cheat again! If you have the Symfony plugin installed, you can put your cursor in the template name and press Alt+Enter to create the Twig template, right next to the other one.

Because we're awesome at Twig, the contents of this are pretty boring. In fact, I'm going to cheat again! I'm on a roll! I'll paste a template I already prepared. You can get this from the code block on this page.

{% extends 'content_base.html.twig' %}
{% block content_body %}
<a href="{{ path('admin_article_new') }}" class="btn btn-primary pull-right">
Create <span class="fa fa-plus-circle"></span>
<h1>All Articles</h1>
<table class="table">
{% for article in articles %}
<td>{{ article.title }}</td>
<td>{{ }}</td>
<span class="fa fa-{{ article.isPublished ? 'check' : 'times' }}"></span>
{% endfor %}
{% endblock %}

And... yea! Beautifully boring! This loops over the articles and prints some basic info about each. I also added a link on top to the new article form page.

Oh, there is one interesting part: the article.isPublished code, which I use to show a check mark or an "x" mark. That's interesting because... we don't have an isPublished property or method on Article! Add public function isPublished(), which will return a bool, and very simply, return $this->publishedAt !== null.

... lines 1 - 15
class Article
... lines 18 - 127
public function isPublished(): bool
return $this->publishedAt !== null;
... lines 132 - 253

If you want to be fancier, you could check to see if the publishedAt date is not null and also not a future date. It's up to how you want your app to work.

Time to try it! Manually go to /admin/article and... woohoo! There is our new article on the bottom.

And... yea! We've already learned enough to create, render and process a form submit! Nice work! Next, let's make things a bit fancier by rendering a success message after submitting.

Leave a comment!

  • 2019-03-15 Victor Bocharsky

    Hey Mike,

    Haha, good catch :) Well, CSRF token should not be unique for each page load, it should be unique for forms and for user session. I think that's how CSRF protection works in Symfony, though I don't know internal details 100%, just saying at the first sight. The idea of CSRF is not protect from double submit, but protect from malicious user sending a request on behalf of user. For this, it's enough to generate a token that is known to server and user only, even if it's not unique per page load.

    I hope it helps.


  • 2019-03-13 Mike

    On the reload of the POST request, it posted the unpublished new article. (5:39 Minutes)
    But shouldnt validation fail instead, because the request was posted with the same csrf token as the request before?
    Isn't that the whole point of CSRF, to create new strings on every page reload to prevent duplicated posting and XSS (Attackers which used old CSRF Tokens)

  • 2019-02-11 weaverryan

    Hey darighteous1!

    No worries about the indentation - Disqus has a way to do code blocks - but it's honestly kind of annoying ;).

    Ok, I *think* I know your problem. I noticed that you did ->setData(['status' => 'new']) on your form when rendering it. That is totally legal and fine. However, when you do this, *all* you're doing is setting the data onto the Form object during that request. Typically the data would then be used by the form to pre-fill some of your fields (e.g. you set 'firstName' => 'Ryan' and then the first name input by has "Ryan" in it). But, in your case, it sounds like you're not rendering any fields - just a submit button. So, when your form submits... it really is submitting absolutely *no* data.

    If you want to send some sort of "flag" to the controller that's handling the submit, I'd recommend either (A) putting it in the URL - e.g. /my/custom/controller/{status} or (B) possibly as a HiddenType

  • 2019-02-08 darighteous1

    Hey, what about if we do not submit the form tho the same end point? I got this case: I have a Sonata admin class, which renders a form. That form submits to a controller. I use Symfony 3.4
    EDIT: Sorry, I can't figure out why indentations are wrong. Code is in a code block :/

    class MySuperAwesomeAdmin extends BaseAdmin
    public function myForm()
    $form = $this->formFactory
    $this->getRouteGenerator()->generate('my_route',['id' => $entity->getId()])
    ->setData(['status' => 'new'])

    return $form->createView();

    In my controller I got:

    class TransferManagementController extends Controller
    public function foo($id, Request $request)
    $form = $this->createForm(FailTransferType::class);
    if($form->isSubmitted() && $form->isValid()) {

    But data is empty. I can't figure out why. Oh, yeah, and my form has just a single submit button that POSTs to my controller. Any idea what am I missing?

  • 2018-11-05 weaverryan

    Hey cybernet2u!

    Ah! I think the problem isn't the paginator, but Doctrine (well really SensioFrameworkExtraBundle) is unable to figure out how to query for your "Advert" entity. My guess is that you have a route that includes a {advert} wildcard, right? When SensioFrameworkExtraBundle sees the Advert $advert type-hint, it tries to query for this Advert object. Unless you have extra configuration, it tries to see if there is a property on your entity called "advert". If there is not, then it can't figure out how to query for your entity object. This is why I often us {id} in my route - because the property is called "id", this "param converter" feature from SensioFrameworkExtraBundle knows how to query.

    But also... this page looks like it is listing many adverts. Did you intend/want to query for a single Advert? I see you'r using it to transform some markdown... but then you don't use that $mk variable. Let me know!


  • 2018-11-03 cybernet2u

    How can we use knp-paginator & knp-markdown-bundle for listing ... a list ?

    i have this

    public function AdminAdvertList(Advert $advert, EntityManagerInterface $em, PaginatorInterface $paginator, Request $request, MarkdownInterface $markdown)
    $q = $request->query->get('q');
    $repository = $em->getRepository(Advert::class);
    $queryBuilder = $repository->getWithSearchQueryBuilder($q);
    $mk = $markdown->transform($advert->getContent());
    $pagination = $paginator->paginate(
    $request->query->getInt('page', 1),
    return $this->render('admin/advert/index.html.twig', [
    'pagination' => $pagination,

    doesn't seem to work anymore after updating symfony

    Unable to guess how to get a Doctrine instance from the request information for parameter "advert".