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.

Syntax{{ 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

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, [
                '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.

Example · twig
{# 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.

Example · twig
{# 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.

Example · yaml
# 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

  • Be the first to comment on this lesson.