Rendering and Theming
Render a form whole or field by field with the Twig form functions, and restyle every field at once with a form theme.
{{ form_start(form) }} {{ form_row(form.field) }} {{ form_end(form) }}A form object does not know what its HTML looks like. Twig renders it, through a small set of functions, and the markup those functions emit comes from a form theme: a Twig template of named blocks, one for each field type and each part of a field.
From whole to field
{{ form(form) }} renders everything - opening tag, every field, hidden fields, closing tag. The longhand produces the same output in pieces, which is what you want the moment layout matters:
{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_row(form.title) }}
{{ form_row(form.body) }}
{{ form_end(form) }}
form_row is itself a shorthand. For one field it expands to form_label, form_widget, form_help and form_errors, in that order, wrapped in the theme's row markup. Call those four yourself when you need your own markup between them. form_start writes the method, the action and the enctype for you. form_end renders every field you have not rendered yet, so nothing is silently dropped.
Themes
The default theme is form_div_layout.html.twig. Symfony ships others, including form_table_layout.html.twig, bootstrap_5_layout.html.twig, bootstrap_5_horizontal_layout.html.twig, tailwind_2_layout.html.twig and daisyui_5_layout.html.twig. Set one application-wide under twig.form_themes, or per template with {% form_theme form 'name.html.twig' %}. Overriding one block in a theme beats hand-writing the same field markup in every template.
Example
<?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, [
'label' => 'Headline',
// attr lands on the input itself.
'attr' => ['maxlength' => 120, 'autofocus' => true],
// row_attr lands on the wrapper the theme draws around it.
'row_attr' => ['data-field' => 'headline'],
])
->add('body', TextareaType::class, [
'help' => 'Markdown is allowed.',
// Render this field with the theme's "editor" blocks
// (editor_widget, editor_row) instead of textarea_widget.
'block_prefix' => 'editor',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Article::class]);
}
}When to use it
- Render a form with your own grid markup instead of one field after another.
- Switch every form in the application to Bootstrap 5 or Tailwind markup in one config entry.
- Wrap one textarea in an editor container without touching any other field.
- Move a field's help text under the input and keep that change in every template.
More examples
Rendering field by field
This is the shape most real templates end up in: rows for the plain fields, hand-built markup for the one or two that need it. The two blocks above render the same four pieces; form_row just gets them from the theme. novalidate turns off browser validation so you can see your server-side messages while developing. Values are escaped by Twig automatically, so there is no |raw anywhere near user input.
{# templates/article/new.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
<h1>New article</h1>
{# Writes <form method="post" ...> with the right enctype. #}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{# Errors attached to the form as a whole, not to one field. #}
{{ form_errors(form) }}
{# Shorthand: label, errors, widget and help in the theme's row. #}
{{ form_row(form.title) }}
{# Longhand: the same four parts, your own markup around them. #}
<div class="field">
{{ form_label(form.body, 'Article body') }}
{{ form_errors(form.body) }}
{{ form_widget(form.body, {attr: {rows: 20}}) }}
<small>{{ form_help(form.body) }}</small>
</div>
<button type="submit">Publish</button>
{# Renders anything left, the CSRF token included, then </form>. #}
{{ form_end(form) }}
{% endblock %}A theme that overrides one block
A theme is just a template of blocks, so {% use %} inherits a complete one and you redefine the few blocks you care about. Block names follow the pattern prefix_part: text_widget, checkbox_row, form_errors, and the prefix you set with block_prefix. Apply it in one template with {% form_theme form 'form/theme.html.twig' %}, or list it in twig.form_themes to apply it everywhere. Reach for this the second time you copy the same wrapper markup around a field.
{# templates/form/theme.html.twig #}
{# Start from a built-in theme, then change only what you need. #}
{% use 'bootstrap_5_layout.html.twig' %}
{# Matches the field that set block_prefix: 'editor'. #}
{% block editor_widget %}
<div class="editor" data-controller="markdown-editor">
{# The parent block still draws the real <textarea>. #}
{{ block('textarea_widget') }}
</div>
{% endblock %}
{# Applies to every checkbox in every form using this theme. #}
{% block checkbox_row %}
<div class="form-check">
{{ form_widget(form) }}
{{ form_label(form) }}
{{ form_errors(form) }}
</div>
{% endblock %}Choosing the theme application-wide
Listing themes here means every form in the application renders the same way without a {% form_theme %} tag in any template. The order is the part people get wrong: the list is consulted in reverse, so your own partial theme goes last and the complete framework theme it builds on goes above it. Use a per-template {% form_theme %} tag only for a form that genuinely needs different markup, such as one embedded in a narrow sidebar.
# config/packages/twig.yaml
twig:
form_themes:
# Searched from the bottom up: the last entry wins, and
# anything it does not define falls back to the one above.
- 'bootstrap_5_layout.html.twig'
- 'form/theme.html.twig'
Discussion