Building a Form

Build a form from a form type class, hand it the request with handleRequest, then act on isSubmitted and isValid.

Syntax$form = $this->createForm(ArticleType::class, $article); $form->handleRequest($request);

A form in Symfony is a PHP object that describes a set of fields. It does three jobs: render HTML inputs, read the submitted request back, and write the values onto an object. Writing the same field list once in a template, again in request-parsing code, and again in a validation check leaves three copies of it in your application. A form keeps one.

The three pieces

A form type is a class extending AbstractType. Its buildForm() method receives a builder and calls add() once per field. A controller turns that class into a live form with createForm(), passing the object the fields belong to. handleRequest() then inspects the request: on a GET it does nothing, on a POST it reads the body and marks the form submitted.

The handler pair

isSubmitted() asks whether data arrived. isValid() asks whether that data passed validation. They are always written together, because on the first GET the answers differ: nothing was submitted, so there is nothing to judge yet.

What trips people up

One route serves both the empty form and the submission, so it must accept both methods. A route declared methods: ['GET'] renders the form and then returns 405 when the browser posts it back. The example route answers GET /articles/new with 200 and an empty form, a valid POST with a 302 to the new article, and an invalid POST with 422 and the same page carrying error messages. That 422 is deliberate: render() lowers the status itself when it is handed a submitted, invalid form.

Example

Example · php
<?php
// src/Controller/ArticleController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use App\Form\ArticleType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleController extends AbstractController
{
    // Both methods: the GET renders the form, the POST submits it.
    #[Route('/articles/new', name: 'app_article_new', methods: ['GET', 'POST'])]
    public function new(Request $request, EntityManagerInterface $em): Response
    {
        $article = new Article();

        // Second argument is the object the form reads from and writes to.
        $form = $this->createForm(ArticleType::class, $article);

        // Reads the POST body and marks the form submitted; a no-op on a GET.
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // $article now holds the submitted values.
            $em->persist($article);
            $em->flush();

            return $this->redirectToRoute('app_article_show', ['id' => $article->getId()]);
        }

        // render() answers 422 when the form was submitted and is invalid.
        return $this->render('article/new.html.twig', ['form' => $form]);
    }
}

When to use it

  • Add a "new article" page where an editor types a title and body and the record is saved.
  • Replace a hand-written HTML form and manual $_POST reading with one definition of the fields.
  • Reuse the same field list on a create page and an edit page without copying the template.

More examples

The form type the controller refers to

A form type is an ordinary class in src/Form. buildForm() lists the fields; configureOptions() declares the class they belong to. Keeping the field list here means the controller, the template and any other place that needs this form all share one definition. Reach for a form type class whenever the form is used more than once, or is longer than two fields.

Example · php
<?php
// src/Form/ArticleType.php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class)
            ->add('body', TextareaType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        // Declares the class this form is built around.
        $resolver->setDefaults([
            'data_class' => Article::class,
        ]);
    }
}

The same form written inline, without a type class

createFormBuilder() is the longhand of createForm(): you describe the fields on the spot instead of naming a class that describes them. The resulting form object is the same, and handleRequest, isSubmitted and isValid behave identically. It is worth knowing because you will meet it in other people's code, but it is only comfortable for a throwaway one-field form, since nothing else can reuse it.

Example · php
<?php
// src/Controller/QuickDraftController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class QuickDraftController extends AbstractController
{
    #[Route('/articles/draft', name: 'app_article_draft', methods: ['GET', 'POST'])]
    public function draft(Request $request): Response
    {
        $article = new Article();

        // Longhand. This builds exactly the form that
        // $this->createForm(ArticleType::class, $article) builds:
        // same fields, same bound object, same handling.
        $form = $this->createFormBuilder($article)
            ->add('title', TextType::class)
            ->add('body', TextareaType::class)
            ->getForm();

        $form->handleRequest($request);

        return $this->render('article/draft.html.twig', ['form' => $form]);
    }
}

A form with no object behind it

A form does not have to be attached to an object. With no bound data and no data_class, getData() returns an array keyed by field name. setMethod('GET') makes the form submit through the query string, which is right for a search box: the result is a shareable URL. Note which bag the values land in, because mixing up $request->query (the query string) and $request->request (the POST body) is the most common way to read an empty value.

Example · php
<?php
// src/Controller/SearchController.php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class SearchController extends AbstractController
{
    #[Route('/search', name: 'app_search', methods: ['GET'])]
    public function search(Request $request): Response
    {
        // No data and no data_class, so the form's data is a plain array.
        $form = $this->createFormBuilder()
            ->setMethod('GET')
            ->add('q', SearchType::class, ['required' => false])
            ->getForm();

        // With method GET, handleRequest reads the query string
        // ($request->query), not the POST body ($request->request).
        $form->handleRequest($request);

        $term = $form->isSubmitted() ? ($form->getData()['q'] ?? null) : null;

        return $this->render('search/index.html.twig', [
            'form' => $form,
            'term' => $term,
        ]);
    }
}

Discussion

  • Be the first to comment on this lesson.