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
Validating answersPage 4 of 20Updated
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.
Type
The server checks
Check yourself
Input field, Textarea
Required; regex; length limits. Surrounding spaces are trimmed first, so spaces alone count as empty
Email
Required; regex; Unique, when ticked, against the form's completed submissions
The address format: the browser checks it with type="email"; add a regex to check it on the server
Phone
Required; regex
The number format: add a regex
Numeric, Slider
That 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
Range
Both numbers, as for Numeric; they are stored lower first
Select, Radio, Checkbox
Required
That 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/Time
Required; within the earliest and latest allowed
File
Required, met only by a file sent as a file part of a multipart request (File uploads)
Type and size
Captcha
Always required, and verified with Google
Hidden input
Required; regex
Treat 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:
Setting
Applies to
Message shown
Required
Every input
Cannot be empty
Regex
Enforced by the server on Input field, Textarea, Email, Phone and Hidden input
Must match specified regex
Minimum and maximum length
Input field, Textarea
Min allowed characters length is 3; Max allowed characters length is 200
Minimum, maximum and step
Numeric, Slider, Range
Min allowed value is 1; Max allowed value is 10; Value has to be divisible by 5; Value has to be a number
Earliest and latest
Date/Time
You should choose a date/time after 2026-10-05.
Unique
Email
This 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:
Leave Required unticked on Company name.
Add a conditional: String Comparison, enquiryTypeIs equal tobusiness. 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:
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:
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:
{% 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:
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.