Field Types and Options

The field types you will actually use, and the options - label, help, required, constraints, mapped - that change what they do.

Syntax$builder->add(string $name, ?string $type = null, array $options = [])

Every field in a form is itself a form type class. TextType, ChoiceType, CheckboxType and the rest live under the namespace Symfony\Component\Form\Extension\Core\Type. The second argument to add() names the type; the third is an array of options that configures it.

Types you will reach for

  • Text and numbers: TextType, TextareaType, EmailType, UrlType, SearchType, IntegerType, NumberType, MoneyType
  • Choices: CheckboxType for one boolean, ChoiceType for a fixed list, EnumType for a PHP enum, EntityType for rows from the database
  • Dates: DateType, DateTimeType, TimeType, each with a widget option that decides between one input and three
  • FileType for uploads, SubmitType when you want the button to be part of the form object

Options that matter

label replaces the label guessed from the field name; false removes it. help prints a line of guidance beside the field. attr adds HTML attributes to the input. constraints attaches validation rules to this field rather than to a class property. required defaults to true and is superficial: it renders the HTML5 required attribute on the input and a required class on the label, and it also decides how empty submitted data is turned into the field's empty_data. It is independent of validation.

mapped defaults to true, meaning the field reads from and writes to the property of the same name. Set it to false and the field still renders and still submits, but the bound object never sees it. Read such a value with $form->get('name')->getData().

Example

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\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
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;
use Symfony\Component\Validator\Constraints as Assert;

final class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class, [
                'label' => 'Headline',
                'help' => 'Shown in search results.',
                'constraints' => [new Assert\Length(max: 120)],
            ])
            ->add('body', TextareaType::class, [
                'attr' => ['rows' => 12],
            ])
            ->add('status', ChoiceType::class, [
                // Keys are the labels, values are what is submitted.
                'choices' => ['Draft' => 'draft', 'Published' => 'published'],
                'placeholder' => 'Choose one',
            ])
            // mapped false: renders and submits, but never touches $article.
            ->add('agreeToPolicy', CheckboxType::class, [
                'mapped' => false,
                'label' => 'I have read the editorial policy',
                'constraints' => [new Assert\IsTrue(message: 'Please confirm.')],
            ]);
    }

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

When to use it

  • Turn a plain text field into a textarea with a row count and a help line.
  • Offer a fixed status list as a select, with a placeholder so nothing is pre-chosen.
  • Add a "confirm" checkbox that must be ticked but is never stored on the entity.
  • Let an editor pick an author from an ordered list of existing records.

More examples

Guessed type and explicit type are the same field

Leaving out the type is a shorthand, not a different mechanism: Symfony inspects the property and picks a type, then builds the field exactly as if you had named it. The guess is useful while sketching a form and in generated code, but it is invisible, so a change to the property type silently changes the widget. State the type once a form is real. The two add() calls above are shown together only to compare them; in one form you would keep one.

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

declare(strict_types=1);

namespace App\Form;

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

final class CommentType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // Shorthand: no type given, so Symfony guesses one from the
        // property's type and its database mapping.
        $builder->add('body');

        // Longhand: the same field, stated outright. Write this when the
        // guess is wrong, or when you need options anyway.
        $builder->add('body', TextareaType::class, [
            'required' => true,
        ]);
    }

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

Reading an unmapped field in the controller

An unmapped field is for input the object should not store: a confirmation checkbox, a "send a notification" toggle, a repeated value you only compare. The form validates it like any other field, and $form->get('name')->getData() hands you the value. The response here is 302 to the article page on a valid submit, and 422 with the form redisplayed when the checkbox is left unticked.

Example · php
<?php
// src/Controller/ArticlePublishController.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 ArticlePublishController extends AbstractController
{
    #[Route('/articles/{id}/edit', name: 'app_article_edit', methods: ['GET', 'POST'])]
    public function edit(Request $request, Article $article, EntityManagerInterface $em): Response
    {
        $form = $this->createForm(ArticleType::class, $article);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            // The unmapped checkbox is not on $article; ask the form for it.
            $confirmed = (bool) $form->get('agreeToPolicy')->getData();

            if ($confirmed) {
                $article->setStatus('published');
            }

            $em->flush();

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

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

A select filled from the database

EntityType turns rows into options and gives you back the object, not an id, so the property it maps to stays an entity. The query_builder closure receives the repository and returns a QueryBuilder, which is how you order the list or hide archived rows. Reach for it on any relation the user picks from a dropdown; once the table is large enough that the list is unusable, swap the select for an autocomplete field instead of loading every row.

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

declare(strict_types=1);

namespace App\Form;

use App\Entity\Article;
use App\Entity\Author;
use App\Repository\AuthorRepository;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ArticleAuthorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('author', EntityType::class, [
            'class' => Author::class,
            // Property or method used for each option's text.
            'choice_label' => 'name',
            // One query builds every option; no query per row.
            'query_builder' => static fn (AuthorRepository $repository): QueryBuilder
                => $repository->createQueryBuilder('a')->orderBy('a.name', 'ASC'),
            'placeholder' => 'Unassigned',
            'required' => false,
        ]);
    }

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

Discussion

  • Be the first to comment on this lesson.