Files
archived-symfony-docs/forms.rst
Javier Eguiluz 0f02579bc9 Merge branch '6.4' into 7.3
* 6.4:
  [Form] Mention conditional constraitns via form validation groups
2026-01-29 17:03:06 +01:00

1759 lines
59 KiB
ReStructuredText

Forms
=====
.. admonition:: Screencast
:class: screencast
Do you prefer video tutorials? Check out the `Symfony Forms screencast series`_.
Creating and processing HTML forms is hard and repetitive. You need to deal with
rendering HTML form fields, validating submitted data, mapping the form data
into objects and a lot more. Symfony includes a powerful form feature that
provides all these features and many more for truly complex scenarios.
Installation
------------
In applications using :ref:`Symfony Flex <symfony-flex>`, run this command to
install the form feature before using it:
.. code-block:: terminal
$ composer require symfony/form
Understanding How Forms Work
----------------------------
Before diving into the code, it's helpful to understand the mental model behind
Symfony forms. Think of a form as a **bidirectional mapping layer** between
your PHP objects (or arrays) and HTML forms.
This mapping works in two directions:
#. **Object to HTML**: When rendering a form, Symfony reads data from your
object and turns it into HTML fields that users can edit;
#. **HTML to Object**: When processing a submission, Symfony takes the raw
values from the HTTP request (typically strings) and converts them back into
the appropriate PHP types on your object.
This flow is the core of the Form component. From simple text fields to complex
nested collections, everything follows the same pattern.
.. _form-data-lifecycle:
The Data Transformation Lifecycle
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Data in a form goes through three representations, often called **data layers**:
**Model Data**
The data in the format your application uses. For example, a ``DateTime``
object, a Doctrine entity, or a custom value object. This is what you pass
to ``createForm()`` and what you get back after a successful submission via
``getData()``.
**Normalized Data**
An intermediate representation that normalizes the model data. For most
field types, this is identical to the model data. However, for some types
it's different. For example, ``DateType`` is normalized as an array with
``year``, ``month``, and ``day`` keys.
**View Data**
The format used to populate HTML form fields and received from user
submissions. In most cases, this is string-based (or arrays of strings),
because browsers submit text. Some fields may use other representations or
remain empty for security reasons (for example, file inputs).
High-level flow:
**Form Rendering**
#. Start with model data from your object.
#. Model transformers convert it to normalized data.
#. View transformers convert it to view data (typically strings).
#. Symfony renders the corresponding HTML widgets.
**Form Submission**
#. Symfony reads raw values from the HTTP request (typically strings).
#. View transformers reverse the data into normalized data.
#. Model transformers reverse the data into model data.
#. The data is written back to the underlying object or array.
For a ``DateType`` field configured to render as three ``<select>`` elements:
* **Model data**: a ``DateTime`` object;
* **Norm data**: an array like ``['year' => 2026, 'month' => 10, 'day' => 18]``;
(values are integers)
* **View data**: an array like ``['year' => '2026', 'month' => '10', 'day' => '18']``
(values are strings, as submitted by the browser).
Most of the time you don't need to think about these layers. They become
relevant when debugging why a field doesn't display or submit correctly, or
when creating custom :doc:`data transformers </form/data_transformers>`.
Usage
-----
The recommended workflow when working with Symfony forms is the following:
#. **Build the form** in a Symfony controller or using a dedicated form class;
#. **Render the form** in a template so the user can edit and submit it;
#. **Process the form** to validate the submitted data, transform it into PHP
data and do something with it (e.g. persist it in a database).
Each of these steps is explained in detail in the next sections. To make
examples easier to follow, all of them assume that you're building a small Todo
list application that displays "tasks".
Users create and edit tasks using Symfony forms. Each task is an instance of the
following ``Task`` class::
// src/Entity/Task.php
namespace App\Entity;
class Task
{
protected string $task;
protected ?\DateTimeInterface $dueDate;
public function getTask(): string
{
return $this->task;
}
public function setTask(string $task): void
{
$this->task = $task;
}
public function getDueDate(): ?\DateTimeInterface
{
return $this->dueDate;
}
public function setDueDate(?\DateTimeInterface $dueDate): void
{
$this->dueDate = $dueDate;
}
}
This class is a "plain-old-PHP-object" because, so far, it has nothing to do
with Symfony or any other library. It's a normal PHP object that directly solves
a problem inside *your* application (i.e. the need to represent a task in your
application). But you can also edit :doc:`Doctrine entities </doctrine>` in the
same way.
.. _form-types:
Form Types
~~~~~~~~~~
Before creating your first Symfony form, it's important to understand the
concept of "form type". In other projects, it's common to differentiate between
"forms" and "form fields". In Symfony, all of them are "form types":
* a single ``<input type="text">`` form field is a "form type" (e.g. ``TextType``);
* a group of several HTML fields used to input a postal address is a "form type"
(e.g. ``PostalAddressType``);
* an entire ``<form>`` with multiple fields to edit a user profile is a
"form type" (e.g. ``UserProfileType``).
This unified concept makes the Form component more **flexible**. You can compose
complex forms from simpler types, embed forms within forms, and reuse the same
type definition across your application.
**The Form Type Hierarchy**
Every form type has a parent type. The parent determines the base behavior,
options, and rendering that your type inherits. Here's a simplified view:
.. code-block:: text
FormType (the root parent of all types)
├─ TextType (renders a text input)
│ ├─ EmailType
│ ├─ PasswordType
│ ├─ ...
│ └─ UrlType
├─ ChoiceType (renders select, radio, or checkboxes)
│ ├─ CountryType
│ ├─ EntityType
│ └─ ...
├─ DateType (renders single or multiple fields for date input)
│ └─ ...
└─ ...
When you create a custom form type and specify a parent (via ``getParent()``),
your type inherits options, template blocks, and behavior from that parent. This
is why ``EmailType`` reuses the rendering and options from ``TextType``.
There are tens of :doc:`form types provided by Symfony </reference/forms/types>`
and you can also :doc:`create your own form types </form/create_custom_field_type>`.
.. tip::
You can use the ``debug:form`` to list all the available types, type
extensions and type guessers in your application:
.. code-block:: terminal
$ php bin/console debug:form
# pass the form type FQCN to only show the options for that type, its parents and extensions
# For built-in types, you can pass the short classname instead of the FQCN
$ php bin/console debug:form BirthdayType
# pass also an option name to only display the full definition of that option
$ php bin/console debug:form BirthdayType label_attr
Building Forms
--------------
Symfony provides a "form builder" object which allows you to describe the form
fields using a fluent interface. Later, this builder creates the actual form
object used to render and process contents.
.. _creating-forms-in-controllers:
Creating Forms in Controllers
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
If your controller extends from the :ref:`AbstractController <the-base-controller-class-services>`,
use the ``createFormBuilder()`` helper::
// src/Controller/TaskController.php
namespace App\Controller;
use App\Entity\Task;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TaskController extends AbstractController
{
public function new(Request $request): Response
{
// creates a task object and initializes some data for this example
$task = new Task();
$task->setTask('Write a blog post');
$task->setDueDate(new \DateTimeImmutable('tomorrow'));
$form = $this->createFormBuilder($task)
->add('task', TextType::class)
->add('dueDate', DateType::class)
->add('save', SubmitType::class, ['label' => 'Create Task'])
->getForm();
// ...
}
}
If your controller does not extend from ``AbstractController``, you'll need to
:ref:`fetch services in your controller <controller-accessing-services>` and
use the ``createBuilder()`` method of the ``form.factory`` service.
In this example, you've added two fields to your form - ``task`` and ``dueDate``
- corresponding to the ``task`` and ``dueDate`` properties of the ``Task``
class. You've also assigned each a :ref:`form type <form-types>` (e.g. ``TextType``
and ``DateType``), represented by its fully qualified class name. Finally, you
added a submit button with a custom label for submitting the form to the server.
.. _creating-forms-in-classes:
Creating Form Classes
~~~~~~~~~~~~~~~~~~~~~
Symfony recommends putting as little logic as possible in controllers. That's why
it's better to move complex forms to dedicated classes instead of defining them
in controller actions. Besides, forms defined in classes can be reused in
multiple actions and services.
Form classes are :ref:`form types <form-types>` that implement
:class:`Symfony\\Component\\Form\\FormTypeInterface`. However, it's better to
extend from :class:`Symfony\\Component\\Form\\AbstractType`, which already
implements the interface and provides some utilities::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('task', TextType::class)
->add('dueDate', DateType::class)
->add('save', SubmitType::class)
;
}
}
.. tip::
Install the `MakerBundle`_ in your project to generate form classes using
the ``make:form`` and ``make:registration-form`` commands.
The form class contains all the directions needed to create the task form. In
controllers extending from the :ref:`AbstractController <the-base-controller-class-services>`,
use the ``createForm()`` helper (otherwise, use the ``create()`` method of the
``form.factory`` service)::
// src/Controller/TaskController.php
namespace App\Controller;
use App\Form\Type\TaskType;
// ...
class TaskController extends AbstractController
{
public function new(): Response
{
// creates a task object and initializes some data for this example
$task = new Task();
$task->setTask('Write a blog post');
$task->setDueDate(new \DateTimeImmutable('tomorrow'));
$form = $this->createForm(TaskType::class, $task);
// ...
}
}
.. _form-data-class:
Every form needs to know the name of the class that holds the underlying data
(e.g. ``App\Entity\Task``). Usually, this is guessed based on the object passed
to the second argument to ``createForm()`` (i.e. ``$task``). Later, when you
begin :doc:`embedding forms </form/embedded>`, this will no longer be sufficient.
So, while not always necessary, it's generally a good idea to explicitly specify
the ``data_class`` option by adding the following to your form type class::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use App\Entity\Task;
use Symfony\Component\OptionsResolver\OptionsResolver;
// ...
class TaskType extends AbstractType
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Task::class,
]);
}
}
.. _form-property-path:
Mapping Fields to Object Properties
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
By default, a form field named ``dueDate`` reads and writes the ``dueDate``
property on your object. This uses the :doc:`PropertyAccess component </components/property_access>`,
which can work with public properties and common accessor names (``get*()``,
``is*()``, ``has*()``, ``set*()``).
The ``property_path`` option lets you customize this mapping.
**Mapping to a Different Property**
If your form field name doesn't match the object property::
$builder->add('deadline', DateType::class, [
// this field will read/write the 'dueDate' property
'property_path' => 'dueDate',
]);
**Mapping to Nested Properties**
You can access nested object properties using dot notation::
// assuming Task::getCategory() returns a Category object with getName()/setName()
$builder->add('categoryName', TextType::class, [
'property_path' => 'category.name',
]);
For fields that shouldn't be written back to the underlying data, use
:ref:`unampped fields <form-unmapped-fields>`.
.. _form-injecting-services:
Injecting Services in Form Classes
..................................
Form classes are regular services, which means you can inject other services
using :doc:`autowiring </service_container/autowiring>`::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use App\Repository\CategoryRepository;
use Symfony\Component\Form\AbstractType;
// ...
class TaskType extends AbstractType
{
public function __construct(
private CategoryRepository $categoryRepository,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// use $this->categoryRepository to access the repository
}
}
If you're using the :ref:`default services.yaml configuration <service-container-services-load-example>`,
this works automatically. See :doc:`/form/create_custom_field_type` for more
information about injecting services in custom form types.
.. _rendering-forms:
Rendering Forms
---------------
Now that the form has been created, the next step is to render it::
// src/Controller/TaskController.php
namespace App\Controller;
use App\Entity\Task;
use App\Form\Type\TaskType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TaskController extends AbstractController
{
public function new(Request $request): Response
{
$task = new Task();
// ...
$form = $this->createForm(TaskType::class, $task);
return $this->render('task/new.html.twig', [
'form' => $form,
]);
}
}
Internally, the ``render()`` method calls ``$form->createView()`` to
transform the form into a *form view* instance.
Then, use some :ref:`form helper functions <reference-form-twig-functions>` to
render the form contents:
.. code-block:: twig
{# templates/task/new.html.twig #}
{{ form(form) }}
That's it! The :ref:`form() function <reference-forms-twig-form>` renders all
fields *and* the ``<form>`` start and end tags. By default, the form method is
``POST`` and the target URL is the same that displayed the form, but
:ref:`you can change both <forms-change-action-method>`.
Notice how the rendered ``task`` input field has the value of the ``task``
property from the ``$task`` object (i.e. "Write a blog post"). This is the first
job of a form: to take data from an object and translate it into a format that's
suitable for being rendered in an HTML form.
.. tip::
The form system is smart enough to access the value of the protected
``task`` property via the ``getTask()`` and ``setTask()`` methods on the
``Task`` class. Unless a property is public, it *must* have a "getter" and
"setter" method so that Symfony can get and put data onto the property. For
a boolean property, you can use an "isser" or "hasser" method (e.g.
``isPublished()`` or ``hasReminder()``) instead of a getter (e.g.
``getPublished()`` or ``getReminder()``).
As short as this rendering is, it's not very flexible. Usually, you'll need more
control about how the entire form or some of its fields look. For example, thanks
to the :doc:`Bootstrap 5 integration with Symfony forms </form/bootstrap5>` you
can set this option to generate forms compatible with the Bootstrap 5 CSS framework:
.. configuration-block::
.. code-block:: yaml
# config/packages/twig.yaml
twig:
form_themes: ['bootstrap_5_layout.html.twig']
.. code-block:: xml
<!-- config/packages/twig.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:twig="http://symfony.com/schema/dic/twig"
xsi:schemaLocation="http://symfony.com/schema/dic/services
https://symfony.com/schema/dic/services/services-1.0.xsd
http://symfony.com/schema/dic/twig
https://symfony.com/schema/dic/twig/twig-1.0.xsd">
<twig:config>
<twig:form-theme>bootstrap_5_layout.html.twig</twig:form-theme>
<!-- ... -->
</twig:config>
</container>
.. code-block:: php
// config/packages/twig.php
use Symfony\Config\TwigConfig;
return static function (TwigConfig $twig): void {
$twig->formThemes(['bootstrap_5_layout.html.twig']);
// ...
};
The :ref:`built-in Symfony form themes <symfony-builtin-forms>` include
Bootstrap 3, 4 and 5, Foundation 5 and 6, as well as Tailwind 2. You can also
:ref:`create your own Symfony form theme <create-your-own-form-theme>`.
In addition to form themes, Symfony allows you to
:doc:`customize the way fields are rendered </form/form_customization>` with
multiple functions to render each field part separately (widgets, labels,
errors, help messages, etc.)
.. _processing-forms:
Processing Forms
----------------
The :ref:`recommended way of processing forms <best-practice-handle-form>` is to
use a single action for both rendering the form and handling the form submit.
You can use separate actions, but using one action simplifies everything while
keeping the code concise and maintainable.
Processing a form means to translate user-submitted data back to the properties
of an object. To make this happen, the submitted data from the user must be
written into the form object::
// src/Controller/TaskController.php
// ...
use Symfony\Component\HttpFoundation\Request;
class TaskController extends AbstractController
{
public function new(Request $request): Response
{
// set up a fresh $task object (remove the example data)
$task = new Task();
$form = $this->createForm(TaskType::class, $task);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// $form->getData() holds the submitted values
// but, the original `$task` variable has also been updated
$task = $form->getData();
// ... perform some action, such as saving the task to the database
return $this->redirectToRoute('task_success');
}
return $this->render('task/new.html.twig', [
'form' => $form,
]);
}
}
This controller follows a common pattern for handling forms and has three
possible paths:
#. When initially loading the page in a browser, the form hasn't been submitted
yet and ``$form->isSubmitted()`` returns ``false``. So, the form is created
and rendered;
#. When the user submits the form, :method:`Symfony\\Component\\Form\\FormInterface::handleRequest`
recognizes this and immediately writes the submitted data back into the
``task`` and ``dueDate`` properties of the ``$task`` object. Then this object
is validated (validation is explained in the next section). If it is invalid,
:method:`Symfony\\Component\\Form\\FormInterface::isValid` returns
``false`` and the form is rendered again, but now with validation errors.
By passing ``$form`` to the ``render()`` method (instead of
``$form->createView()``), the response code is automatically set to
`HTTP 422 Unprocessable Content`_. This ensures compatibility with tools
relying on the HTTP specification, like `Symfony UX Turbo`_;
#. When the user submits the form with valid data, the submitted data is again
written into the form, but this time :method:`Symfony\\Component\\Form\\FormInterface::isValid`
returns ``true``. Now you have the opportunity to perform some actions using
the ``$task`` object (e.g. persisting it to the database) before redirecting
the user to some other page (e.g. a "thank you" or "success" page);
.. note::
Redirecting a user after a successful form submission is a best practice
that prevents the user from being able to hit the "Refresh" button of
their browser and re-post the data.
Accessing Form Data
~~~~~~~~~~~~~~~~~~~
You'll use the ``getData()`` method most often to access the form's data,
but Symfony forms also provide methods to access data at :ref:`each layer <form-data-lifecycle>`:
``getData()``
Returns the **model data**. This is the method you'll use most often.
After submission, it returns the populated object (or array) with all the
submitted values transformed into their proper PHP types.
``getNormData()``
Returns the **normalized data**. Useful when debugging transformer issues
or when you need the intermediate representation.
``getViewData()``
Returns the **view data**. This is what gets rendered into HTML fields and
what comes back from user submissions (before transformation).
.. seealso::
When adding :ref:`extra fields <form-extra-fields>` to the form, you can also
use the ``getExtraData()`` method to get any submitted data that doesn't
correspond to a form field.
Example showing these methods in action::
// after form submission
$form->handleRequest($request);
if ($form->isSubmitted()) {
// the populated Task object
$task = $form->getData();
// for a DateType field, this might be ['year' => 2024, 'month' => 6, ...]
$normData = $form->get('dueDate')->getNormData();
// the raw submitted values (usually strings): ['year' => '2024', 'month' => '6', ...]
$viewData = $form->get('dueDate')->getViewData();
}
If a transformer fails, the form (or the affected field) may be marked as not
synchronized. Check ``isSynchronized()`` and inspect field errors to understand
what went wrong.
.. _processing-forms-submit-method:
Using the submit() Method
~~~~~~~~~~~~~~~~~~~~~~~~~
The :method:`Symfony\\Component\\Form\\FormInterface::handleRequest` method is
the recommended way to process forms. However, you can also use the
:method:`Symfony\\Component\\Form\\FormInterface::submit` method for finer
control over when exactly your form is submitted and what data is passed to it::
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// ...
public function new(Request $request): Response
{
$task = new Task();
$form = $this->createForm(TaskType::class, $task);
if ($request->isMethod('POST')) {
$form->submit($request->getPayload()->get($form->getName()));
if ($form->isSubmitted() && $form->isValid()) {
// perform some action...
return $this->redirectToRoute('task_success');
}
}
return $this->render('task/new.html.twig', [
'form' => $form,
]);
}
The list of fields submitted with the ``submit()`` method must be the same as
the fields defined by the form class. Otherwise, you'll see a form validation error::
public function new(Request $request): Response
{
// ...
if ($request->isMethod('POST')) {
// '$json' represents payload data sent by React/Angular/Vue
// the merge of parameters is needed to submit all form fields
$form->submit(array_merge($json, $request->getPayload()->all()));
// ...
}
// ...
}
.. tip::
Forms consisting of nested fields expect an array in
:method:`Symfony\\Component\\Form\\FormInterface::submit`. You can also submit
individual fields by calling :method:`Symfony\\Component\\Form\\FormInterface::submit`
directly on the field::
$form->get('firstName')->submit('Fabien');
.. tip::
When submitting a form via a "PATCH" request, you may want to update only a few
submitted fields. To achieve this, you may pass an optional second boolean
argument to ``submit()``. Passing ``false`` will remove any missing fields
within the form object. Otherwise, the missing fields will be set to ``null``.
.. warning::
When the second parameter ``$clearMissing`` is ``false``, like with the
"PATCH" method, the validation will only apply to the submitted fields. If
you need to validate all the underlying data, add the required fields
manually so that they are validated::
// 'email' and 'username' are added manually to force their validation
$form->submit(array_merge(
['email' => null, 'username' => null],
$request->getPayload()->all()
), false);
.. _processing-forms-multiple-buttons:
Handling Multiple Submit Buttons
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When your form contains more than one submit button, you'll want to check
which of the buttons was clicked to adapt the program flow in your controller.
For example, if you add a second button with the caption "Save and Add" to your form::
$form = $this->createFormBuilder($task)
->add('task', TextType::class)
->add('dueDate', DateType::class)
->add('save', SubmitType::class, ['label' => 'Create Task'])
->add('saveAndAdd', SubmitType::class, ['label' => 'Save and Add'])
->getForm();
In your controller, use the button's
:method:`Symfony\\Component\\Form\\ClickableInterface::isClicked` method for
querying if the "Save and Add" button was clicked::
if ($form->isSubmitted() && $form->isValid()) {
// ... perform some action, such as saving the task to the database
$nextAction = $form->get('saveAndAdd')->isClicked()
? 'task_new'
: 'task_success';
return $this->redirectToRoute($nextAction);
}
Alternatively you can use the :method:`Symfony\\Component\\Form\\Form::getClickedButton`
method to get the clicked button's name::
if ($form->getClickedButton() && 'saveAndAdd' === $form->getClickedButton()->getName()) {
// ...
}
// when using nested forms, two or more buttons can have the same name;
// in those cases, compare the button objects instead of the button names
if ($form->getClickedButton() === $form->get('saveAndAdd')) {
// ...
}
.. _validating-forms:
Validating Forms
----------------
In the previous section, you learned how a form can be submitted with valid
or invalid data. In Symfony, the question isn't whether the "form" is valid, but
whether or not the underlying object (``$task`` in this example) is valid after
the form has applied the submitted data to it. Calling ``$form->isValid()`` is a
shortcut that asks the ``$task`` object whether or not it has valid data.
Before using validation, add support for it in your application:
.. code-block:: terminal
$ composer require symfony/validator
Validation is done by adding a set of rules, called (validation) constraints,
to a class. You can add them either to the entity class or by using the
:ref:`constraints option <reference-form-option-constraints>` of form types.
To see the first approach - adding constraints to the entity - in action,
add the validation constraints, so that the ``task`` field cannot be empty,
and the ``dueDate`` field cannot be empty, and must be a valid ``DateTimeImmutable``
object.
.. configuration-block::
.. code-block:: php-attributes
// src/Entity/Task.php
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
class Task
{
#[Assert\NotBlank]
public string $task;
#[Assert\NotBlank]
#[Assert\Type(\DateTimeInterface::class)]
protected \DateTimeInterface $dueDate;
}
.. code-block:: yaml
# config/validator/validation.yaml
App\Entity\Task:
properties:
task:
- NotBlank: ~
dueDate:
- NotBlank: ~
- Type: \DateTimeInterface
.. code-block:: xml
<!-- config/validator/validation.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<constraint-mapping xmlns="http://symfony.com/schema/dic/constraint-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/constraint-mapping
https://symfony.com/schema/dic/constraint-mapping/constraint-mapping-1.0.xsd">
<class name="App\Entity\Task">
<property name="task">
<constraint name="NotBlank"/>
</property>
<property name="dueDate">
<constraint name="NotBlank"/>
<constraint name="Type">\DateTimeInterface</constraint>
</property>
</class>
</constraint-mapping>
.. code-block:: php
// src/Entity/Task.php
namespace App\Entity;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;
class Task
{
// ...
public static function loadValidatorMetadata(ClassMetadata $metadata): void
{
$metadata->addPropertyConstraint('task', new Assert\NotBlank());
$metadata->addPropertyConstraint('dueDate', new Assert\NotBlank());
$metadata->addPropertyConstraint(
'dueDate',
new Assert\Type(\DateTimeInterface::class)
);
}
}
That's it! If you re-submit the form with invalid data, you'll see the
corresponding errors printed out with the form.
To see the second approach - adding constraints to the form - refer to
:ref:`this section <form-option-constraints>`. Both approaches can be used together.
.. _form-disabling-validation:
Disabling Validation
~~~~~~~~~~~~~~~~~~~~
Sometimes it's useful to suppress the validation of a form altogether. For
these cases, set the ``validation_groups`` option to ``false``::
use Symfony\Component\OptionsResolver\OptionsResolver;
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'validation_groups' => false,
]);
}
Note that when you do that, the form will still run basic integrity checks,
for example whether an uploaded file was too large or whether non-existing
fields were submitted.
The submission of extra form fields can be controlled with the
:ref:`allow_extra_fields config option <form-option-allow-extra-fields>` and
the maximum upload file size should be handled via your PHP and web server
configuration.
You can also disable validation for specific submit buttons using
``'validation_groups' => false``. This is useful in multi-step forms when you
want a "Previous" button to save data without running validation::
$form = $this->createFormBuilder($task)
// ...
->add('nextStep', SubmitType::class)
->add('previousStep', SubmitType::class, [
'validation_groups' => false,
])
->getForm();
The form will still validate basic integrity constraints even when clicking
"previousStep".
Other Common Form Features
--------------------------
Passing Options to Forms
~~~~~~~~~~~~~~~~~~~~~~~~
If you :ref:`create forms in classes <creating-forms-in-classes>`, when building
the form in the controller you can pass custom options to it as the third optional
argument of ``createForm()``::
// src/Controller/TaskController.php
namespace App\Controller;
use App\Form\Type\TaskType;
// ...
class TaskController extends AbstractController
{
public function new(): Response
{
$task = new Task();
// use some PHP logic to decide if this form field is required or not
$dueDateIsRequired = ...;
$form = $this->createForm(TaskType::class, $task, [
'require_due_date' => $dueDateIsRequired,
]);
// ...
}
}
If you try to use the form now, you'll see an error message: *The option
"require_due_date" does not exist.* That's because forms must declare all the
options they accept using the ``configureOptions()`` method::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use Symfony\Component\OptionsResolver\OptionsResolver;
// ...
class TaskType extends AbstractType
{
// ...
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
// ...,
'require_due_date' => false,
]);
// you can also define the allowed types, allowed values and
// any other feature supported by the OptionsResolver component
$resolver->setAllowedTypes('require_due_date', 'bool');
}
}
Now you can use this new form option inside the ``buildForm()`` method::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// ...
->add('dueDate', DateType::class, [
'required' => $options['require_due_date'],
])
;
}
// ...
}
Form Type Options
~~~~~~~~~~~~~~~~~
Each :ref:`form type <form-types>` has a number of options to configure it, as
explained in the :doc:`Symfony form types reference </reference/forms/types>`.
Two commonly used options are ``required`` and ``label``.
The ``required`` Option
.......................
The most common option is the ``required`` option, which can be applied to any
field. By default, this option is set to ``true``, meaning that HTML5-ready
browsers will require you to fill in all fields before submitting the form.
If you don't want this behavior, either
:ref:`disable client-side validation <forms-html5-validation-disable>` for the
entire form or set the ``required`` option to ``false`` on one or more fields::
->add('dueDate', DateType::class, [
'required' => false,
])
The ``required`` option does not perform any server-side validation. If a user
submits a blank value for the field (either with an old browser or a web
service, for example), it will be accepted as a valid value unless you also use
Symfony's ``NotBlank`` or ``NotNull`` validation constraints.
The ``label`` Option
....................
By default, the label of form fields are the *humanized* version of the
property name (``user`` -> ``User``; ``postalAddress`` -> ``Postal Address``).
Set the ``label`` option on fields to define their labels explicitly::
->add('dueDate', DateType::class, [
// set it to FALSE to not display the label for this field
'label' => 'To Be Completed Before',
])
.. tip::
By default, ``<label>`` tags of required fields are rendered with a
``required`` CSS class, so you can display an asterisk by applying a CSS style:
.. code-block:: css
label.required:before {
content: "*";
}
.. _forms-change-action-method:
Changing the Action and HTTP Method
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
By default, the ``<form>`` tag is rendered with a ``method="post"`` attribute,
and no ``action`` attribute. This means that the form is submitted via an HTTP
POST request to the same URL under which it was rendered. When building the form,
use the ``setAction()`` and ``setMethod()`` methods to change this::
// src/Controller/TaskController.php
namespace App\Controller;
// ...
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
class TaskController extends AbstractController
{
public function new(): Response
{
// ...
$form = $this->createFormBuilder($task)
->setAction($this->generateUrl('target_route'))
->setMethod('GET')
// ...
->getForm();
// ...
}
}
When building the form in a class, pass the action and method as form options::
// src/Controller/TaskController.php
namespace App\Controller;
use App\Form\TaskType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
// ...
class TaskController extends AbstractController
{
public function new(): Response
{
// ...
$form = $this->createForm(TaskType::class, $task, [
'action' => $this->generateUrl('target_route'),
'method' => 'GET',
]);
// ...
}
}
Finally, you can override the action and method in the template by passing them
to the ``form()`` or the ``form_start()`` helper functions:
.. code-block:: twig
{# templates/task/new.html.twig #}
{{ form_start(form, {'action': path('target_route'), 'method': 'GET'}) }}
.. note::
If the form's method is not ``GET`` or ``POST``, but ``PUT``, ``PATCH`` or
``DELETE``, Symfony will insert a hidden field with the name ``_method``
that stores this method. The form will be submitted in a normal ``POST``
request, but :doc:`Symfony's routing </routing>` is capable of detecting the
``_method`` parameter and will interpret it as a ``PUT``, ``PATCH`` or
``DELETE`` request. The :ref:`http_method_override <configuration-framework-http_method_override>`
option must be enabled for this to work.
.. _changing-the-form-name:
Changing the Form Field Names and Ids
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When Symfony renders a form, it generates HTML ``name`` and ``id`` attributes
for each field following specific conventions. Understanding these conventions
helps when writing JavaScript, CSS selectors, or custom form themes.
In Twig templates, prefer ``form.vars.full_name`` and ``form.vars.id`` as the
source of truth, instead of reconstructing names manually.
**The ``name`` Attribute**
Field names follow the pattern: ``formName[fieldName]``. For nested forms, names
are further nested: ``formName[childForm][fieldName]``.
Given a ``TaskType`` form with a ``dueDate`` field::
$form = $this->createForm(TaskType::class, $task);
The rendered HTML will have:
.. code-block:: html
<input name="task[dueDate]" ...>
For a ``DateType`` field that renders as three separate ``<select>`` elements:
.. code-block:: html
<select name="task[dueDate][month]">...</select>
<select name="task[dueDate][day]">...</select>
<select name="task[dueDate][year]">...</select>
**The ``id`` Attribute**
The ``id`` attribute follows a similar pattern but uses underscores instead of
brackets: ``formName_fieldName``. For the examples above:
.. code-block:: html
<input id="task_dueDate" ...>
<!-- or for DateType with multiple fields: -->
<select id="task_dueDate_month">...</select>
<select id="task_dueDate_day">...</select>
<select id="task_dueDate_year">...</select>
**Customizing the Form Name**
The default form name is derived from the form type class (for example,
``TaskType`` becomes ``task`` and ``FooBarType`` becomes ``foo_bar``). You can
customize this by returning a different value from the ``getBlockPrefix()`` method
of your form type class.
You can also customize this by creating the form with the
:method:`Symfony\\Component\\Form\\FormFactoryInterface::createNamed` method::
// using FormFactory
$form = $formFactory->createNamed('my_task', TaskType::class, $task);
// this generates: <input name="my_task[dueDate]" id="my_task_dueDate">
To create a form without any name prefix (fields named directly like ``dueDate``
instead of ``task[dueDate]``)::
$form = $formFactory->createNamed('', TaskType::class, $task);
.. _forms-html5-validation-disable:
Client-Side HTML Validation
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Thanks to HTML5, many browsers can natively enforce certain validation
constraints on the client side. The most common validation is activated by
adding a ``required`` attribute on fields that are required. For browsers
that support HTML5, this will result in a native browser message being displayed
if the user tries to submit the form with that field blank.
Generated forms take full advantage of this new feature by adding sensible HTML
attributes that trigger the validation. The client-side validation, however, can
be disabled by adding the ``novalidate`` attribute to the ``<form>`` tag or
``formnovalidate`` to the submit tag. This is especially useful when you want to
test your server-side validation constraints, but are being prevented by your
browser from, for example, submitting blank fields.
.. code-block:: twig
{# templates/task/new.html.twig #}
{{ form_start(form, {'attr': {'novalidate': 'novalidate'}}) }}
{{ form_widget(form) }}
{{ form_end(form) }}
.. _form-type-guessing:
Form Type Guessing
~~~~~~~~~~~~~~~~~~
If the object handled by the form includes validation constraints, Symfony can
introspect that metadata to guess the type of your field.
In the above example, Symfony can guess from the validation rules that the
``task`` field is a normal ``TextType`` field and the ``dueDate`` field is a
``DateType`` field.
To enable Symfony's "guessing mechanism", omit the second argument to the ``add()`` method, or
pass ``null`` to it::
// src/Form/Type/TaskType.php
namespace App\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
// if you don't define field options, you can omit the second argument
->add('task')
// if you define field options, pass NULL as second argument
->add('dueDate', null, ['required' => false])
->add('save', SubmitType::class)
;
}
}
.. warning::
When using a specific :doc:`form validation group </form/validation_groups>`,
the field type guesser will still consider *all* validation constraints when
guessing your field types (including constraints that are not part of the
validation group(s) being used).
Form Type Options Guessing
..........................
When the guessing mechanism is enabled for some field, in addition to its form type,
the following options will be guessed too:
``required``
The ``required`` option is guessed based on the validation rules (i.e. is
the field ``NotBlank`` or ``NotNull``) or the Doctrine metadata (i.e. is the
field ``nullable``). This is very useful, as your client-side validation will
automatically match your validation rules.
``maxlength``
If the field is some sort of text field, then the ``maxlength`` option attribute
is guessed from the validation constraints (if ``Length`` or ``Range`` is used)
or from the :doc:`Doctrine </doctrine>` metadata (via the field's length).
If you'd like to change one of the guessed values, override it in the options field array::
->add('task', null, ['attr' => ['maxlength' => 4]])
.. seealso::
Besides guessing the form type, Symfony also guesses :ref:`validation constraints <validating-forms>`
if you're using a Doctrine entity. Read :ref:`automatic_object_validation`
guide for more information.
.. _form-unmapped-fields:
Unmapped Fields
~~~~~~~~~~~~~~~
When editing an object via a form, all form fields are considered properties of
the object. Any fields on the form that do not exist on the object will cause an
exception to be thrown.
If you need extra fields in the form that won't be stored in the object (for
example to add an *"I agree with these terms"* checkbox), set the ``mapped``
option to ``false`` in those fields::
// ...
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('task')
->add('dueDate')
->add('agreeTerms', CheckboxType::class, ['mapped' => false])
->add('save', SubmitType::class)
;
}
}
These "unmapped fields" can be set and accessed in a controller with::
$form->get('agreeTerms')->getData();
$form->get('agreeTerms')->setData(true);
Additionally, if there are any fields on the form that aren't included in
the submitted data, those fields will be explicitly set to ``null``.
.. _form-extra-fields:
Extra fields
~~~~~~~~~~~~
By default, Symfony expects every submitted field to be defined in the form. Any
additional submitted fields are treated as "extra fields". You can access them via the
:method:`FormInterface::getExtraData() <Symfony\\Component\\Form\\FormInterface::getExtraData>` method.
For example, consider a user creation form::
// ...
use App\User;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
class UserCreateType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('username', TextType::class)
->add('email', EmailType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
You can render an additional input in the template without adding it to the form
definition:
.. code-block:: html+twig
{# templates/user/create.html.twig #}
{{ form_start(form) }}
{{ form_row(form.username) }}
{{ form_row(form.email) }}
{# hidden field to send an additional referral code #}
<input type="hidden" name="{{ form.vars.full_name ~ '[referralCode]' }}" value="{{ referralCode }}"/>
<button type="submit">Submit</button>
{{ form_end(form) }}
In this example, ``referralCode`` is submitted as an extra field and you can
read it like this::
$extraData = $form->getExtraData();
$referralCode = $extraData['referralCode'] ?? null;
.. note::
To accept extra fields, set the :ref:`allow_extra_fields <form-option-allow-extra-fields>`
option to ``true``. Otherwise, the form will be invalid.
.. _forms-without-class:
Using a Form without a Data Class
---------------------------------
In most applications, a form is tied to an object, and the fields of the form get
and store their data on the properties of that object. This is what you've seen
so far in this article with the ``Task`` class.
However, by default, a form actually assumes that you want to work with arrays
of data, instead of an object. There are exactly two ways that you can change
this behavior and tie the form to an object instead:
#. Pass an object when creating the form (as the first argument to ``createFormBuilder()``
or the second argument to ``createForm()``);
#. Declare the ``data_class`` option on your form.
If you *don't* do either of these, then the form will return the data as an array.
In this example, since ``$defaultData`` is not an object (and no ``data_class``
option is set), ``$form->getData()`` ultimately returns an array::
// src/Controller/ContactController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// ...
class ContactController extends AbstractController
{
public function contact(Request $request): Response
{
$defaultData = ['message' => 'Type your message here'];
$form = $this->createFormBuilder($defaultData)
->add('name', TextType::class)
->add('email', EmailType::class)
->add('message', TextareaType::class)
->add('send', SubmitType::class)
->getForm();
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// data is an array with "name", "email", and "message" keys
$data = $form->getData();
}
// ... render the form
}
}
.. tip::
You can also access POST values (in this case "name") directly through
the request object, like so::
$request->getPayload()->get('name');
Be advised, however, that in most cases using the ``getData()`` method is
a better choice, since it returns the data (usually an object) after
it's been transformed by the Form component.
.. _form-without-class-validation:
Adding Validation
~~~~~~~~~~~~~~~~~
Usually, when you call ``$form->handleRequest($request)``, the object is validated
by reading the constraints that you applied to that class. If your form is mapped
to an object, this is almost always the approach you want to use. See
:doc:`/validation` for more details.
.. _form-option-constraints:
But if the form is not mapped to an object and you instead want to retrieve an
array of your submitted data, there are two ways to add constraints to the form data.
Constraints At Field Level
..........................
You can attach constraints to the individual fields. The overall approach is
covered a bit more in :doc:`this validation article </validation/raw_values>`,
but here's a short example::
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'constraints' => new Assert\Length(['min' => 3]),
])
->add('lastName', TextType::class, [
'constraints' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 3]),
],
])
;
}
.. tip::
If you are using validation groups, you need to either reference the
``Default`` group when creating the form, or set the correct group on
the constraint you are adding::
new NotBlank(['groups' => ['create', 'update']]);
.. tip::
If the form is not mapped to an object, every object in your array of
submitted data is validated using the ``Symfony\Component\Validator\Constraints\Valid``
constraint, unless you :ref:`disable validation <disabling-validation>`.
.. warning::
When a form is only partially submitted (for example, in an HTTP PATCH
request), only the constraints from the submitted form fields will be
evaluated.
Constraints At Class Level
..........................
You can also add the constraints at the class level. This can be done by setting
the ``constraints`` option in the ``configureOptions()`` method::
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class)
->add('lastName', TextType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => null,
'constraints' => new Assert\Collection([
'firstName' => new Assert\Length(['min' => 3]),
'lastName' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 3]),
],
]),
]);
}
This means you can also do this when using the ``createFormBuilder()`` method
in your controller::
use Symfony\Component\Validator\Constraints as Assert;
$form = $this->createFormBuilder($defaultData, [
'constraints' => [
'firstName' => new Assert\Length(['min' => 3]),
'lastName' => [
new Assert\NotBlank(),
new Assert\Length(['min' => 3]),
],
],
])
->add('firstName', TextType::class)
->add('lastName', TextType::class)
->getForm();
Conditional Constraints
.......................
It's possible to define field constraints that depend on the value of other
fields (e.g. a field must not be blank when another field has a certain value).
To achieve this, use the ``expression`` option of the
:doc:`When constraint </reference/constraints/When>` to reference the other field::
use Symfony\Component\Validator\Constraints as Assert;
$builder
->add('how_did_you_hear', ChoiceType::class, [
'required' => true,
'label' => 'How did you hear about us?',
'choices' => [
'Search engine' => 'search_engine',
'Friends' => 'friends',
'Other' => 'other',
],
'expanded' => true,
'constraints' => [
new Assert\NotBlank(),
]
])
// this field is only required when 'how_did_you_hear' is 'other'
->add('other_text', TextType::class, [
'required' => false,
'label' => 'Please specify',
'constraints' => [
new Assert\When(
expression: 'this.getParent().get("how_did_you_hear").getData() == "other"',
constraints: [
new Assert\NotBlank(),
],
)
],
])
;
.. seealso::
Another way to implement conditional constraints is to configure the
``validation_groups`` form option with a callable. See
:doc:`/form/validation_groups`.
Troubleshooting
---------------
Why Doesn't My Field Value Display?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: The form renders, but a field is empty even though the underlying
data has a value.
**Common causes**:
#. The property is not readable (missing accessor, wrong name, or not public).
For booleans, Symfony also looks for ``is*()`` and ``has*()`` accessors.
#. The field name doesn't match the property name. Use :ref:`property_path <form-property-path>`
if you need to map to a different property.
#. The data is set after creating the form. Populate your object *before*
passing it to ``createForm()``::
// wrong: object populated after form creation
$form = $this->createForm(TaskType::class, $task);
$task->setTask('My task');
// correct: object populated before form creation
$task->setTask('My task');
$form = $this->createForm(TaskType::class, $task);
Why Doesn't My Submitted Data Save to the Object?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: The form submits, but the object properties remain unchanged.
**Common causes**:
#. The property is not writable (missing setter, wrong name, or not public).
#. It is an :ref:`unmapped field <form-unmapped-fields>`.
#. The form is not synchronized due to a transformation failure. Check
``isSynchronized()`` and inspect field errors.
Why Does ``getData()`` Return ``null`` After Submission?
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
**Problem**: ``$form->getData()`` is ``null`` after handling the request.
**Common causes**:
#. No initial object (or default data) was provided and the form doesn't create
one. Review the form's ``data_class`` and ``empty_data`` options.
#. A transformation failed and the form is not synchronized. Check
``isSynchronized()`` and field errors.
#. The form is not submitted or is invalid. Check ``isSubmitted()`` and
``isValid()`` before using the data.
Learn more
----------
When building forms, remember that the first goal of a form is to translate
data from an object (``Task``) to an HTML form so that the user can modify that
data. The second goal of a form is to take the data submitted by the user and to
re-apply it to the object.
There's a lot more to learn and a lot of *powerful* tricks in the Symfony forms:
Reference:
.. toctree::
:maxdepth: 1
/reference/forms/types
Advanced Features:
.. toctree::
:maxdepth: 1
/controller/upload_file
/security/csrf
/form/create_custom_field_type
/form/data_transformers
/form/data_mappers
/form/create_form_type_extension
/form/type_guesser
Form Themes and Customization:
.. toctree::
:maxdepth: 1
/form/bootstrap4
/form/bootstrap5
/form/tailwindcss
/form/form_customization
/form/form_themes
Events:
.. toctree::
:maxdepth: 1
/form/events
/form/dynamic_form_modification
Validation:
.. toctree::
:maxdepth: 1
/form/validation_groups
Misc.:
.. toctree::
:maxdepth: 1
/form/embedded
/form/form_collections
/form/inherit_data_option
/form/unit_testing
/form/use_empty_data
.. _`Symfony Forms screencast series`: https://symfonycasts.com/screencast/symfony-forms
.. _`MakerBundle`: https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html
.. _`HTTP 422 Unprocessable Content`: https://www.rfc-editor.org/rfc/rfc9110.html#name-422-unprocessable-content
.. _`Symfony UX Turbo`: https://ux.symfony.com/turbo