Building forms with the Forms module: how a form is put together and placed on a page, validating and branching, presenting it, where submissions are stored, and what happens after one arrives, from jobs and records to CRMs and payments.
L1–L2
Unit 04 · Presenting a form
Rendering and styling
Presenting a formPage 6 of 20Updated
The markup the platform prints for every field and the classes to style; replacing one input type's markup without forking the form, and what you then take over; and styling a form to a design without fighting the platform.
Who this is for. The theme developer who has to make a form match a design. Afterwards you know the markup the platform prints and the classes to style, how to replace one input type's markup without forking the form, and when a hand-built form is the right call.
A form layout decides where the fields go, but each field's own markup comes from the platform: $element->getHtml() builds it in PHP.
The markup the platform produces
Every input is printed in the same shape: a wrapper holding a label, the control, and, when the last submit failed, an error after the control. This is a text input from a real form, as the browser receives it after a failed submit:
HTML
<div class="base-element-block input-element-block">
<label class="element-label element-text form-label" for="element-25-customerName">What should we call you?</label>
<input id="element-25-customerName" class="element-input inputText form-control"
name="customerName" type="text" placeholder="" title="" value="" required
data-id="19341869-6826-4613-9fc2-955428f5de26">
<div class="input-element-validation-errors">Cannot be empty</div>
</div>
The parts, and what to hang CSS on:
Part
Markup
Use
Wrapper of every input
div.base-element-block.input-element-block, plus the element's custom classes
.input-element-block. The browser's conditional logic shows and hides this wrapper, so do not put layout on the control alone
Label
label.element-label.element-text.form-label, tied to the control by for
.element-label
Control
.element-input plus the editor name as a class: inputText, inputEmail, inputTextarea, inputNumber, inputTel, inputDatetime, inputSelect, inputRange, inputSlider, inputFile, inputCheckbox, inputRadio
.element-input for all, .inputSelect and the rest for one type
Error
div.input-element-validation-errors, after the control
.input-element-validation-errors. Style a failing field with .input-element-block:has(.input-element-validation-errors)
Id
element-<form id>-<field name>; options add -radio-0, -checkbox-0 and so on
Stable while the field name is, so usable for one-off styling and for links from an error summary
data-id
The element's own id, a UUID, on the control
Do not style on it: the browser's conditional logic finds fields by it
The other shapes, in brief:
Select: select.element-input.inputSelect.form-select. A single select starts with an empty option showing the field's placeholder, disabled until something is chosen. Option titles written Group::Option become <optgroup>s.
Radio and checkbox lists: the label is a div.element-label, not a <label> or <legend>, followed by one label.list-group-item per option wrapping its input. A radio list sits in div.radio-list.list-group, a checkbox list in div.checkbox-list.list-group. A single checkbox is an input followed by a label.form-check-label. The wrapper of every Checkbox element also carries form-check.
Range: two number inputs in a div.input-group, separated by span.input-group-text. Both inputs carry the same id.
Hidden input: the bare <input type="hidden">, with no wrapper.
Submit button: a bare <button type="submit">, with no wrapper or class.
Group: fieldset.element-block.element-group-block with a legend.element-label.element-text, then its fields.
Richtext: div.element-block.static-element-block around div.element-text. An HTML snippet is printed as entered, with no wrapper.
They include Bootstrap 5's names.form-control, form-select, form-check-input, form-range, list-group and input-group are printed whatever the theme uses. On a Bootstrap site they look right with no CSS. On any other site they mean nothing, so target the platform's own names (.element-input, .input-element-block) and leave the Bootstrap ones alone, so that a later theme that adds Bootstrap does not fight your rules.
Editors can add classes. Each input has a Custom CSS classes setting in the form builder, added to its wrapper. Agree a small set with the editors (half-width, inline-options) and style those, rather than one-off ids.
The form tag is yours. The platform prints the elements; the form layout prints <form>, the buttons and the hidden fields (a minimal layout). Put a class on the form tag and scope every rule under it, so form styles never leak into the rest of the site.
Overriding one input
Each input's markup is built by the platform; to change it, print your own markup for that element. getHtml() builds it in PHP, and no setting or path convention replaces it. What you can decide, in the form layout, is which elements to print yourself and which to leave to the platform: a layout that loops over the elements, sends a few of them (by name or by type) to the site's own partials, and calls getHtml() for everything else.
The pattern below makes that a convention. One partial, forms/partials/element.html, prints any element. It looks the element's editor up in a list of overrides and includes that partial if there is one; otherwise it prints the platform's markup. It also handles groups itself, because a group's getHtml() prints its fields in PHP too, and an override would never reach a field inside a group.
Template code
{* templates/forms/partials/element.html: one form element. Site markup where an override is listed, the platform's otherwise *}
{$overrides = ['inputRadio' => 'forms/partials/radio-fieldset.html']}
{if $element->getEditor() == 'group'}
<fieldset class="element-block element-group-block" data-id="{$element->getElementId()}">
<legend class="element-label element-text">{$element->getText()|escape}</legend>
{foreach $element->getChildren() as $child}
{include file='forms/partials/element.html' element=$child form=$form}
{/foreach}
</fieldset>
{elseif isset($overrides[$element->getEditor()])}
{include file=$overrides[$element->getEditor()] element=$element form=$form}
{else}
{$element->getHtml()}
{/if}
{# templates/forms/partials/element.html: one form element. Site markup where an override is listed, the platform's otherwise #}
{% set overrides = {'inputRadio': 'forms/partials/radio-fieldset.html'} %}
{% if element.getEditor() == 'group' %}
<fieldset class="element-block element-group-block" data-id="{{ element.getElementId() }}">
<legend class="element-label element-text">{{ element.getText()|e }}</legend>
{% for child in element.getChildren() %}
{% include 'forms/partials/element.html' with {element: child, form: form} %}
{% endfor %}
</fieldset>
{% elseif overrides[element.getEditor()] is defined %}
{% include overrides[element.getEditor()] with {element: element, form: form} %}
{% else %}
{{ element.getHtml()|raw }}
{% endif %}
In the form layout, the loop over the step's elements then includes that partial instead of calling getHtml():
Template code
{foreach $step->getElements()->filtered('parent', null) as $element}
{include file='forms/partials/element.html' element=$element form=$form}
{/foreach}
{% for element in step.getElements().filtered('parent', null) %}
{% include 'forms/partials/element.html' with {element: element, form: form} %}
{% endfor %}
The fallback is the point: an editor who adds a field type you have not overridden still gets working markup. To override one field rather than a whole type, key a second list on $element->getName() and check it first. Never override the captcha or payment elements; their markup is tied to scripts they print themselves.
The override: a radio list as a fieldset
The platform labels a radio list with a div, so a screen reader announces each option without the question it answers. A <fieldset> with a <legend> fixes that, and gives you markup to style as cards. The partial:
{# templates/forms/partials/radio-fieldset.html: a radio list with a real legend #}
{% set name = element.getName() %}
{% set baseId = 'element-' ~ form.getId() ~ '-' ~ name %}
{% set current = element.getDefaultValue() %}
{% set submission = form.getCurrentSubmission() %}
{% if submission is not null %}
{% set answers = submission.getRedirectData() %}
{% if answers[name] is defined %}{% set current = answers[name] %}{% endif %}
{% endif %}
<fieldset class="base-element-block input-element-block radio-cards"{% if element.hasErrors() %} aria-describedby="{{ baseId }}-error"{% endif %}>
<legend class="element-label">{{ element.getText()|e }}</legend>
{% for option in element.getOptions() %}
<label class="radio-card" for="{{ baseId }}-radio-{{ loop.index0 }}">
<input id="{{ baseId }}-radio-{{ loop.index0 }}" class="element-input inputRadio" type="radio"
name="{{ name|e('html_attr') }}" value="{{ option.value|e('html_attr') }}" data-id="{{ element.getElementId() }}"
{% if option.value is same as(current) %}checked{% endif %}
{% if loop.first and element.isRequired() %}required{% endif %}
{% if element.hasErrors() %}aria-invalid="true"{% endif %}>
<span>{{ option.title|e }}</span>
</label>
{% endfor %}
{% if element.hasErrors() %}
<div class="input-element-validation-errors" id="{{ baseId }}-error">{{ element.getErrorMessage()|e }}</div>
{% endif %}
</fieldset>
What you take over
Printing an input yourself means doing everything getHtml() did for it. The partial above keeps all of it, and each line is there for a reason:
The name. The server reads the answer by the field's name, and nothing else. A different name submits nothing, and a required field then fails with "Cannot be empty" although the visitor answered.
The answer after a failed submit. On a page-per-step form the page is built again after a rejection, and only your template can put the visitor's choice back. getRedirectData() on the current submission returns the answers so far, keyed by name; a field with no answer is not in it, so fall back to the element's default. This is the part most often dropped. A production email override filled the field only for signed-in members, so every anonymous visitor lost their address on any error.
The error.hasErrors() and getErrorMessage() are on the element. Without them a rejected field looks accepted, and the visitor resubmits the same answer.
The ids. Keep the platform's id pattern, so an error summary written against it still links to the field.
data-id on each control, and the base-element-block class on the wrapper. The browser's conditional logic finds a field by data-id and shows or hides its closest .base-element-block. Keep it on every control you add, so conditional logic can find this field and read its answer in the browser.
Escaping.getOptions() returns values and titles exactly as the editor typed them, unencoded, and the platform's own markup encodes them. Do the same.
Test an override by failing it on purpose: submit with a different field invalid and check your field still shows its answer, then submit with your field empty and check its message appears. Then check any conditional rule that targets it, in the browser and against the server.
Styling to a design
The approach that does not fight the platform, in order of preference:
Style the produced structure. Most designs need nothing more. Scope the rules under the class on your form tag:
Override individual inputs, with the pattern above, where the markup itself is wrong for the design or for accessibility: radio cards, a combined date picker, a phone field with a country selector.
Do not rebuild the form in the template. A layout that hardcodes each field's markup by name looks finished on the day it is built. The next field an editor adds needs new markup to appear, a renamed field needs its markup updated to keep submitting, and every conditional rule needs its data-id copied by hand.
When a custom form is warranted. Some interfaces are not forms an editor should configure: a booking widget that checks availability as you type, a calculator, a configurator whose questions come from product data. Build those as your own front end, and keep the Forms module as the store: post the answers to the form submission route as JSON, under the field names the form defines, so validation, storage, exports and jobs keep working. What you give up is the editor's control: a new question becomes a code change. Choose that on purpose, and say so to the client.