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 02 · Validating answers

Validation rules and error handling

What each input type checks without configuration, what to check yourself, the rules you configure (with a regex that works on both sides), what a server rejection returns, and the patterns that make errors accessible and keep what the visitor typed.

Who this is for. The theme developer and the site builder responsible for a form's error handling. Afterwards you know what the platform checks without being asked, which rules you can configure, what a server rejection looks like in each submission model, and how to show errors so that every visitor can find them and nobody loses what they typed.

Every answer is checked twice: once in the browser, from the attributes the platform prints on each field, and again on the server, which is the check that counts. The browser check is a convenience that saves a round trip. Anyone can switch it off or send a request without it, so nothing on the server depends on it.

What you get without configuring anything

The rows below are what the server enforces on every submit; the browser attributes are listed in the Form input reference.

TypeThe server checksCheck yourself
Input field, TextareaRequired; regex; length limits. Surrounding spaces are trimmed first, so spaces alone count as empty
EmailRequired; regex; Unique, when ticked, against the form's completed submissionsThe address format: the browser checks it with type="email"; add a regex to check it on the server
PhoneRequired; regexThe number format: add a regex
Numeric, SliderThat it is a number; minimum; maximum; step. Spaces are removed and a comma is read as a decimal point, so 1 000 is stored as 1000 and 1,5 as 1.5
RangeBoth numbers, as for Numeric; they are stored lower first
Select, Radio, CheckboxRequiredThat the answer is one of the options, and the number of choices on a multi-select or checkbox list: check them in whatever acts on the answer
Date/TimeRequired; within the earliest and latest allowed
FileRequired, met only by a file sent as a file part of a multipart request (File uploads)Type and size
CaptchaAlways required, and verified with Google
Hidden inputRequired; regexTreat the value as visitor input

So do not rebuild required-field, number, length or date-range checks in your own JavaScript: the platform does them on both sides. Cover the right-hand column yourself: a regex where one fits, and option answers and hidden values treated as visitor input in anything that acts on a submission, such as a job.

Rules you configure

Every rule is a setting on the element, in the form builder:

SettingApplies toMessage shown
RequiredEvery inputCannot be empty
RegexEnforced by the server on Input field, Textarea, Email, Phone and Hidden inputMust match specified regex
Minimum and maximum lengthInput field, TextareaMin allowed characters length is 3; Max allowed characters length is 200
Minimum, maximum and stepNumeric, Slider, RangeMin allowed value is 1; Max allowed value is 10; Value has to be divisible by 5; Value has to be a number
Earliest and latestDate/TimeYou should choose a date/time after 2026-10-05.
UniqueEmailThis value is already used

The messages come from the platform's forms translations and are the same for every field. Put the expected format in the label or in a Richtext element beside the field, and use the error summary below, which pairs each message with its field's label.

The data model also attaches validation rule and validation condition records to each element. A rule that applies only in certain circumstances is built with conditional logic instead (the second example below).

Worked example: a postcode, and how to write a regex

An Australian postcode is four digits. On an Input field named postcode, set Regex to:

^[0-9]{4}$

The browser receives it as the field's pattern attribute, and the server runs it on submit. A visitor who types 20000 is stopped by the browser, and a request that skips the browser is refused by the server with "Must match specified regex". Label the field "Postcode (4 digits)", so the visitor knows the rule before breaking it.

Write the pattern so that the browser and the server read it the same way. Four rules:

  • No delimiters and no flags. Write the bare pattern, ^[0-9]{4}$, not /^[0-9]{4}$/. For case-insensitive matching, use character classes: ^[Aa][Bb][Cc]$.
  • Escape every slash as \/, and end the pattern with something other than a slash. For a web address, write ^https?:\/\/.+$.
  • Anchor it with ^ and $. The browser always matches the whole value, but the server looks for the pattern anywhere in it. Without anchors, [0-9]{4} is refused by the browser for abc2000xyz and accepted by the server.
  • Keep to syntax both sides share: character classes, ?, *, +, {n,m}, groups and |, with a hyphen inside a class escaped ([a-z0-9\-]). Current browsers compile pattern strictly, so a pattern kept to this syntax is checked on both sides.

Empty answers skip the regex, so an optional postcode can be left blank. Tick Required as well when it must be given.

Give step a whole number, and for money collect cents or whole amounts. On a Slider, leave step empty; use Numeric when an exact step matters.

Worked example: required only sometimes

An enquiry form asks Enquiry type (a select with the values personal and business), and Company name must be given only for a business enquiry. This is not a validation setting but a conditional rule, built like this:

  1. Leave Required unticked on Company name.
  2. Add a conditional: String Comparison, enquiryType Is equal to business. Give it two actions: Field Visibility, show Company name, and Field Requirements, set required on Company name.

Set the requirement through the conditional, as here, rather than with Required. The reasons, and the other safe patterns, are on Conditional logic.

What a server rejection looks like

Page per step. The page is built again with the same step, each answer back in its field, and a message after each failing field, inside its wrapper:

HTML
<div class="base-element-block input-element-block">
  <label class="element-label element-text form-label" for="element-12-postcode">Postcode (4 digits)</label>
  <input id="element-12-postcode" class="element-input inputText form-control" name="postcode" type="text"
         value="20000" pattern="^[0-9]{4}$" required data-id="…">
  <div class="input-element-validation-errors">Must match specified regex</div>
</div>

Style the message by its class, input-element-validation-errors (Rendering and styling). The answers and the step were saved with the error, so fixing the field and submitting again continues the same submission. A captcha must be solved again on every attempt.

The API (the JS model, or your own client posting to /cdna-api/forms/submission/<form id>) answers with one of two errors (every status):

  • 422: nothing was saved. This happens when the form requires a completed captcha and it was not.
  • 207: the submission was saved, but some answers are invalid, so it is not complete. A second POST creates a second submission.

Both carry the errors under validationErrors, keyed by field name, each a list of violations with a message:

JSON
"validationErrors": {
  "postcode": [ { "message": "Must match specified regex", "propertyPath": "postcode", "code": "…" } ],
  "email":    [ { "message": "Cannot be empty", "propertyPath": "email", "code": "…" } ]
}

Accessible errors

The platform prints each message next to its field. Two additions to the form layout make the errors clear to every visitor: a summary that is announced and links to each problem, and attributes that tie each field to its message.

The first is an error summary above the form, built on the server, so it works without JavaScript. It lists each failing field by its label and links to it. It loops over every element of the step, including those inside groups, and asks only inputs whether they have errors:

Template code
{$step = $form->getCurrentStep()}
{$errors = []}
{foreach $step->getElements() as $element}
  {if strpos($element->getEditor(), 'input') === 0 && $element->hasErrors()}
    {$id = 'element-'|cat:$form->getId()|cat:'-'|cat:$element->getName()}
    {if $element->getEditor() == 'inputRadio'}{$id = $id|cat:'-radio-0'}{/if}
    {if $element->getEditor() == 'inputCheckbox' && $element->isMultiple()}{$id = $id|cat:'-checkbox-0'}{/if}
    {$errors[] = ['id' => $id, 'label' => $element->getText(), 'message' => $element->getErrorMessage()]}
  {/if}
{/foreach}
{if count($errors)}
  <div class="form-errors" role="alert" tabindex="-1">
    <h2>Please check {count($errors)} {if count($errors) == 1}answer{else}answers{/if}</h2>
    <ul>
      {foreach $errors as $error}
        <li><a href="#{$error.id}">{$error.label}: {$error.message}</a></li>
      {/foreach}
    </ul>
  </div>
{/if}
{% set step = form.getCurrentStep() %}
{% set errors = [] %}
{% for element in step.getElements() %}
  {% if element.getEditor() starts with 'input' and element.hasErrors() %}
    {% set id = 'element-' ~ form.getId() ~ '-' ~ element.getName() %}
    {% if element.getEditor() == 'inputRadio' %}{% set id = id ~ '-radio-0' %}{% endif %}
    {% if element.getEditor() == 'inputCheckbox' and element.isMultiple() %}{% set id = id ~ '-checkbox-0' %}{% endif %}
    {% set errors = errors|merge([{ id: id, label: element.getText(), message: element.getErrorMessage() }]) %}
  {% endif %}
{% endfor %}
{% if errors|length %}
  <div class="form-errors" role="alert" tabindex="-1">
    <h2>Please check {{ errors|length }} {{ errors|length == 1 ? 'answer' : 'answers' }}</h2>
    <ul>
      {% for error in errors %}
        <li><a href="#{{ error.id }}">{{ error.label }}: {{ error.message }}</a></li>
      {% endfor %}
    </ul>
  </div>
{% endif %}

element-<form id>-<name> is the id the platform gives each field. Radio buttons and checkbox lists number their options instead (-radio-0, -checkbox-0), so the link goes to the first option. On a GET nothing has errors, so the summary only appears after a failed submit.

The second is a few lines of script that tie each message to its field and move focus to the summary:

JavaScript
document.querySelectorAll('.input-element-validation-errors').forEach((message, index) => {
  const block = message.closest('.input-element-block');
  if (!block) return;
  message.id = message.id || 'form-error-' + index;
  block.querySelectorAll('input, select, textarea').forEach((field) => {
    field.setAttribute('aria-invalid', 'true');
    field.setAttribute('aria-describedby', message.id);
  });
});
document.querySelector('.form-errors')?.focus();

Keep it in the theme's JavaScript file. An inline script needs the page's nonce, nonce="{$_inlineScriptNonce}", or the Content Security Policy refuses it without a message.

Keeping what the visitor typed

Losing a long form's contents on a validation error is the worst experience a long form can give. Whether it happens depends on the model.

Page per step keeps everything except files. The answers were saved with the error and are printed back into the fields. A browser never refills a file input, so a step with an upload should put it last, or say plainly that the file has to be chosen again. Do not reset or rebuild the form's markup in your own script after a failed submit.

On a form with Require completed captcha ticked, put the captcha on the last step, so the long questions are saved by earlier submits before the visitor reaches it (Captcha).

Handling validation errors in the JS model

On a rejected final submit (207, 422, a 500), show the visitor what went wrong and keep what they typed with the data-submit-result-callback attribute (js-forms.js reference): build each field's message as a new element, never by rewriting the field's wrapper with innerHTML +=, which would restore every field in it to its original markup and lose what was typed. The platform calls it with the response before doing anything itself, and does nothing further when it returns false. Point the container at it (data-submit-result-callback="formResult") and handle errors there:

JavaScript
window.formResult = ({ xhr, formContainer }) => {
  // Success: let the platform show the result step, or redirect
  if (xhr.status >= 200 && xhr.status < 300 && xhr.status !== 207) {
    return true;
  }

  const body = xhr.response || {};
  const errors = body.validationErrors || {};
  let firstField = null;

  formContainer.querySelectorAll('.form-error-js').forEach((old) => old.remove());

  Object.keys(errors).forEach((name) => {
    const field = formContainer.querySelector(`[name="${name}"], [name="${name}[]"]`);
    const block = field && field.closest('.input-element-block');
    if (!block) return;

    const message = document.createElement('div');
    message.className = 'input-element-validation-errors form-error-js';
    message.id = 'form-error-' + name;
    message.textContent = errors[name].map((error) => error.message).join(' ');
    block.appendChild(message);   // appended, never innerHTML: the typed values stay

    field.setAttribute('aria-invalid', 'true');
    field.setAttribute('aria-describedby', message.id);
    firstField = firstField || field;
  });

  // The platform disabled the buttons when the submit started; give them back
  formContainer.querySelectorAll('button[type="submit"]').forEach((button) => button.removeAttribute('disabled'));

  const general = formContainer.querySelector('.form-step:not(.js-hidden) .submit-error-message');
  if (general) {
    general.textContent = Object.keys(errors).length
      ? 'Please check the highlighted answers.'
      : 'The form could not be sent. Please try again.';
    general.classList.remove('js-hidden');
  }

  // The first error may be on an earlier step: go there through a .go-to-step link,
  // so the platform's own record of the current step stays right
  if (firstField) {
    const stepNumber = firstField.closest('.form-step').dataset.step;
    const link = formContainer.querySelector(`.form-step:not(.js-hidden) .go-to-step[data-step="${stepNumber}"]`);
    if (link) link.click();
    setTimeout(() => firstField.focus(), 100);
  }

  return false;
};

For the jump back to work, each step needs a .go-to-step link for every earlier step, such as the progress list on Multi-step forms with its completed items as links. A 207 has already stored an incomplete submission, so the second attempt will be a second submission. Expect duplicates among the incomplete ones.

Go deeper

Was this page helpful?

Your answer goes to the docs team.