Guide 10 · Developer docs

Forms and data capture

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 01 · Building a form

How a form is put together

The four levels of a form and what each is for, why a form is a revisioned record with its own layout areas, the two ways to put a form on a page, what happens on submit, and a minimal form layout.

Who this is for. The theme developer building a site's first form layout, and the content modeller deciding where a form's copy belongs. Afterwards you can tell which level of a form a requirement belongs to, place a form on a page, and write a layout that renders and submits it.

Form, steps, groups, elements

An editor builds a form in the admin under Interactions › Forms. A form is a tree four levels deep, and knowing which level a requirement belongs to settles most design questions before they start.

LevelWhat it is forNotes
FormThe record: its title, an identifier templates can find it by, a layout, and what happens on submitRevisioned, like a page (below)
StepOne screen of the form. A one-page form has a single stepA step is normal (it collects answers) or result (shown after a successful submit)
Element groupFields that belong together: an address, a name in two partsRenders as a <fieldset> with a <legend>, and can be shown or hidden as a unit
ElementA field the visitor fills in, a static element (rich text or an HTML snippet), a submit button or a paymentEvery input has a name, which is the key of its answer in every submission, export and job

A simple contact form has one normal step and either a result step or a redirect:

  • Step 1 (normal): Name, Email, Message, a captcha.
  • Step 2 (result): a rich-text "Thanks, we'll be in touch within one working day."

A multi-step form uses the same pieces, one screen per step. A course enquiry, for example:

  • Step 1, "Your course": Course (select), Intake (select, shown only for some courses).
  • Step 2, "About you": a group Name holding First name and Last name, then Email and Phone.
  • Step 3, "Anything else": a rich-text paragraph, then Comments (textarea) and a captcha.
  • Step 4, "Done" (result): a rich-text confirmation.

Two rules apply to every input in the tree. Its name may contain only letters, digits and underscores, up to 190 characters ("This field can only contain numbers, letters, and underscore characters"). And it must be unique across the whole form, on every step, ignoring case ("Element names must be unique"). The admin saves a form once every name follows both rules. Groups and static elements are not inputs, and the rules do not apply to them.

A form is a record

A form is revisioned, has a layout, and carries its own layout areas, like a page. Saving a change creates a new revision, and a revision can be prepared and activated later. The layout chosen on the form's Content tab decides both which template renders it and which layout areas the editor fills in. So the copy around the fields (a heading, an introduction, a privacy note) is authored on the form, in the admin, not written into the template.

Teams that do not know this hardcode an introduction into the form layout, and the next form to use that layout inherits text about the wrong thing. Give the layout areas instead (The fields of a layout area) and read them in the template. There is one difference from a page: the form object has no layout area getters. The layout areas belong to its active revision.

Template code
{$revision = $form->getActiveRevision()}
{$heading = $revision->heading}
{$intro = $revision->intro}
{if $heading}<h2>{$heading}</h2>{/if}
{if $intro}<div class="form-intro">{$intro}</div>{/if}
{% set revision = form.getActiveRevision() %}
{% set heading = revision.heading %}
{% set intro = revision.intro %}
{% if heading %}<h2>{{ heading }}</h2>{% endif %}
{% if intro %}<div class="form-intro">{{ intro }}</div>{% endif %}

$form->getAreaText() does not exist, and calling it is an error. Copy that sits between fields, such as instructions before a group or a consent statement, belongs in the form itself as a static element.

Revisions have one consequence worth knowing early. A submission records the revision it was made against, and its answers are stored under the names that revision's fields had. Renaming a field on a live form splits its history: older submissions keep the old name, newer ones the new name. Choose names before launch and leave them alone.

Putting a form on a page

A form renders through the template of the layout chosen on the form. There are two ways to put it on a page:

  1. A layout area of the type Form, on a page or a component. The editor picks the form, and the page template prints the layout area. The Form input type is not one a site starts with: create it under Customization › Input Types, as a record type for forms, before adding the layout area.
  2. By identifier, through $_forms, when a template always shows the same form, as a footer newsletter sign-up does. The identifier is set on the form's Settings tab.
Template code
{* 1. from a layout area of the type Form *}
{$page->getAreaHtml('form')}

{* 2. by the form's identifier *}
{$signup = $_forms->get('newsletter')}
{if !is_null($signup)}{$signup->getHtml()}{/if}
{# 1. from a layout area of the type Form #}
{{ page.getAreaHtml('form') }}

{# 2. by the form's identifier #}
{% set signup = _forms.get('newsletter') %}
{% if signup is not null %}{{ signup.getHtml() }}{% endif %}

Inside the form's layout template the form is $form (form in Twig), alongside the usual global variables. Give the form a layout with a template, and check the log while building it: a typo in a form template is written there. When a form does not appear, check its layout first.

What happens on submit

The form's Settings tab holds its identifier, an optional site, an external id, Require completed captcha (Captcha) and the OnSubmit action, one of two:

  • Show the result step. The form must end with a result step, and have only one. A submitted, complete form then renders that step.
  • Redirect the user to the Redirect URL, which is then required.

The admin saves a form once its action has a result step at the end, or a URL. How the answers travel from the page to the platform depends on which of the two submission models the layout uses; the rest of this page is the simpler one, page per step.

A minimal form layout, page per step

A form layout is a template like any other: create it under Design › Layouts and choose it on the form. This one renders the current step, carries the fields the platform reads (_formId, _step, _submit and the submission _token), and shows the result step once the form is complete.

Template code
{$step = $form->getCurrentStep()}
{$submission = $form->getCurrentSubmission()}

<form method="post" class="site-form"{if $step->hasFileInput()} enctype="multipart/form-data"{/if}>
  {foreach $step->getElements()->filtered('parent', null) as $element}
    {$element->getHtml()}
  {/foreach}

  {if $step->getType() == 'normal'}
    <input type="hidden" name="_formId" value="{$form->getId()}">
    <input type="hidden" name="_step" value="{$step->getPosition()}">
    <input type="hidden" name="_submit" value="1">
    {if !is_null($submission)}<input type="hidden" name="_token" value="{$submission->getToken()}">{/if}
    {if isset($_csrfToken)}<input type="hidden" name="_csrf" value="{$_csrfToken}">{/if}
    <button type="submit">Send</button>
  {/if}
</form>
{% set step = form.getCurrentStep() %}
{% set submission = form.getCurrentSubmission() %}

<form method="post" class="site-form"{% if step.hasFileInput() %} enctype="multipart/form-data"{% endif %}>
  {% for element in step.getElements().filtered('parent', null) %}
    {{ element.getHtml() }}
  {% endfor %}

  {% if step.getType() == 'normal' %}
    <input type="hidden" name="_formId" value="{{ form.getId() }}">
    <input type="hidden" name="_step" value="{{ step.getPosition() }}">
    <input type="hidden" name="_submit" value="1">
    {% if submission is not null %}<input type="hidden" name="_token" value="{{ submission.getToken() }}">{% endif %}
    {% if _csrfToken is defined %}<input type="hidden" name="_csrf" value="{{ _csrfToken }}">{% endif %}
    <button type="submit">Send</button>
  {% endif %}
</form>

Four details in it matter:

  • filtered('parent', null) is needed because a step's element list includes the fields inside its groups as well as the groups themselves. Without it, every grouped field is printed twice: once inside its group and once on its own.
  • _formId is how the platform knows which form on the page was submitted. Two forms on one page are fine; each ignores a submit that is not its own.
  • _token ties the steps of one visitor's submission together. Without it, every step starts a new submission.
  • The CSRF field is checked only when a signed-in member submits. Public forms are not CSRF-checked, and $_csrfToken exists only for a signed-in visitor, so guard it as shown (Sessions, caching and security).

The button here is the layout's own. A form can also carry a Submit button element, so the editor chooses its label and position. Use one or the other, not both. What the elements print, and how to style or replace it, is on Rendering and styling.

Go deeper

Was this page helpful?

Your answer goes to the docs team.