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 04 · Presenting a form

Client-side helpers

The four helpers production sites ship with their forms: an international phone field, timezone capture, prefilling from a link or from the signed-in member, and campaign capture. What each contributes, and where its code lives.

Who this is for. The front-end developer adding behaviour to a site's forms. Afterwards you can ship the four helpers production sites use (an international phone field, timezone capture, prefilling, and campaign capture), each writing into fields the platform stores.

The platform ships three scripts for forms: /assets/forms/js-forms.js, which runs the JS submission model, /assets/forms/js-forms-conditionals.js, which applies conditional logic in the browser, and /assets/forms/js-payment-element.js, which runs a payment element. Everything else a form does in the browser is the site's own code.

Where the code lives

In the theme repository, as one small module per helper, bundled into a forms script that the form layout loads after the platform's:

src/js/forms/index.js        imports the helpers below and runs them
src/js/forms/phone.js        international phone input
src/js/forms/prefill.js      timezone, link prefill and campaign capture

Load the bundle from the form layout, not from every page, and with defer, so it runs after the form is in the page and after the platform's scripts. Give every <script> tag the page's nonce, files included. The platform's default policy runs a file from the site's own domain without one, but the policy is a site setting, and a site that adds 'strict-dynamic' to it ignores the domain and runs only scripts that carry the nonce. See Register a page-specific JS bundle with a CSP nonce. The version parameter is the cache-buster.

Template code
<script src="/assets/forms/js-forms.js" nonce="{$_inlineScriptNonce}" defer></script>
<script src="/assets/forms/js-forms-conditionals.js" nonce="{$_inlineScriptNonce}" defer></script>
<script src="/dist/forms.js?v={$_currentSite->getLastVcsHash()}" nonce="{$_inlineScriptNonce}" defer></script>
<script src="/assets/forms/js-forms.js" nonce="{{ _inlineScriptNonce }}" defer></script>
<script src="/assets/forms/js-forms-conditionals.js" nonce="{{ _inlineScriptNonce }}" defer></script>
<script src="/dist/forms.js?v={{ _currentSite.getLastVcsHash() }}" nonce="{{ _inlineScriptNonce }}" defer></script>

Each helper writes into fields the editor creates in the form builder, usually Hidden input elements with agreed names. That is what makes the value part of the submission: it is stored, exported and passed to jobs like any answer. A value a script puts anywhere else is lost on submit.

A helper's value is a hint, never proof. A hidden input can be changed by anyone, and the server checks it only against its regex. Nothing that decides money, access or identity may rest on one.

International phone input

What it contributes. The Phone input is a plain <input type="tel">; add a regex to check the format on the server (below). The helper adds a country picker and validates the number for that country as the visitor types, and on change it rewrites the answer in E.164 form (+61412345678). Every submission then stores the same format, which a CRM or SMS gateway accepts as it is. Sites use the intl-tel-input library, installed with npm and bundled into the forms script. It is also on cdnjs, which the platform's script policy allows, but a bundled copy loads with the rest of the form and has no third-party dependency.

JavaScript
// src/js/forms/phone.js
import intlTelInput from 'intl-tel-input/build/js/intlTelInputWithUtils';

const instances = new WeakMap();

export function enhancePhones(root = document) {
  root.querySelectorAll('input[type="tel"]').forEach((field) => {
    if (instances.has(field)) return;
    const iti = intlTelInput(field, { initialCountry: 'au', nationalMode: false, strictMode: true });
    instances.set(field, iti);

    const check = () => {
      const ok = field.value.trim() === '' || iti.isValidNumber();
      field.setCustomValidity(ok ? '' : 'Enter a valid phone number, including the country.');
      return ok;
    };
    field.addEventListener('input', check);
    field.addEventListener('change', () => {
      if (check() && field.value.trim() !== '') field.value = iti.getNumber();
    });
  });
}

Three details in it matter:

  • One instance per field. A version that stored the library's instance in a single global, in a loop over every phone field, validated the last field on the page for all of them. A form with a mobile and a landline number accepted any mobile. The WeakMap keeps each field's own.
  • setCustomValidity, not a click handler. It makes the number part of the browser's own validation, so a bad number stops the submit the same way an empty required field does: on the page-per-step model, and on every step of the JS model, which also leaves submission to the browser's checks. Validation wired to a submit button's click is skipped by a visitor who presses Enter.
  • The answer is rewritten on change, not on submit. The JS model reads every step's answers when the last step is submitted, and a submit handler of your own may run after it has.

Back it on the server: once every answer arrives in E.164, set the Phone element's Regex to ^\+[1-9][0-9]{6,14}$, so a request that skips the browser is checked too.

Timezone capture

What it contributes. A Date/Time answer is the browser's text, such as 2026-10-05T14:30, with no timezone. For a booking or a callback request, the visitor's timezone is what makes it a moment. The helper fills any field named timezone with the browser's IANA zone name, such as Australia/Sydney; add a Hidden input of that name to the form.

JavaScript
// src/js/forms/prefill.js (1 of 3)
export function fillTimezone(root = document) {
  const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
  root.querySelectorAll('input[name="timezone"]').forEach((field) => {
    if (!field.value) field.value = zone;
  });
}

Store the zone name, not an offset such as +10:00. An offset is wrong for half the year wherever daylight saving applies, and the name is what scheduling tools accept.

Prefilling from a link

What it contributes. A link from an email or a course page arrives with the context already chosen (/enrol?course=WS-104), and the form should not ask again. The helper copies listed query parameters into fields of the same name.

JavaScript
// src/js/forms/prefill.js (2 of 3)
const FROM_THE_LINK = ['course', 'event', 'source'];   // never personal data

export function prefillFromLink(root = document) {
  const params = new URLSearchParams(window.location.search);
  FROM_THE_LINK.forEach((name) => {
    const value = params.get(name);
    if (value === null) return;
    root.querySelectorAll(`[name="${name}"]`).forEach((field) => {
      if (!field.value) field.value = value.slice(0, 200);
    });
  });
}
  • A list of names, not every parameter. A helper that fills any field whose name matches any parameter lets anyone send a link that pre-answers the whole form, consent boxes included.
  • Never personal data in the link. An address in ?email= ends up in the server's access logs, in analytics, and in the browser history of a shared computer. For a signed-in member, print what you know from the template instead (below); for a visitor arriving from your own previous page, hand the value over in sessionStorage, which never leaves the browser.
  • Setting a field read-only is presentation only. A posted value can be anything, whatever the page showed.

To prefill from the signed-in member, have the template print the values as data and the helper copy them in. The platform does not write a page it renders for a signed-in visitor into its response cache, so the member's details are not served to anyone else (Authenticated requests and the cache).

Template code
{if $_currentUser->isLoggedIn()}
  {$person = $_currentUser->getPerson()}
  <script type="application/json" id="form-prefill">{['email' => $person->getEmail(), 'firstName' => $person->getFirstName(), 'lastName' => $person->getLastName()]|json_encode}</script>
{/if}
{% if _currentUser.isLoggedIn() %}
  {% set person = _currentUser.getPerson() %}
  <script type="application/json" id="form-prefill">{{ {'email': person.getEmail(), 'firstName': person.getFirstName(), 'lastName': person.getLastName()}|json_encode|raw }}</script>
{% endif %}
JavaScript
// src/js/forms/prefill.js, continued
export function prefillMember(root = document) {
  const data = document.getElementById('form-prefill');
  if (!data) return;
  const values = JSON.parse(data.textContent);
  Object.entries(values).forEach(([name, value]) => {
    root.querySelectorAll(`[name="${name}"]`).forEach((field) => { if (!field.value) field.value = value ?? ''; });
  });
}

A type="application/json" block is data, not script, so it needs no nonce, and json_encode escapes </ so a value cannot close the tag.

Campaign capture

What it contributes. Marketing wants to know which campaign produced each enquiry. The utm_source, utm_medium and utm_campaign parameters are on the landing URL, but the visitor fills the form three pages later. The helper keeps them in the browser for a set time and writes them into Hidden inputs of the same names on any form.

JavaScript
// src/js/forms/prefill.js (3 of 3)
const KEY = 'formAttribution';
const KEEP_FOR_DAYS = 90;

export function captureCampaign() {
  const found = {};
  new URLSearchParams(window.location.search).forEach((value, key) => {
    if (key.startsWith('utm_')) found[key] = value.slice(0, 200);
  });
  try {
    if (Object.keys(found).length) {
      localStorage.setItem(KEY, JSON.stringify({ values: found, expires: Date.now() + KEEP_FOR_DAYS * 864e5 }));
      return found;
    }
    const saved = JSON.parse(localStorage.getItem(KEY) || 'null');
    if (saved && saved.expires > Date.now()) return saved.values;
    localStorage.removeItem(KEY);
  } catch (e) {
    // Storage can be blocked; attribution is best effort
  }
  return found;
}

export function fillCampaign(values, root = document) {
  Object.entries(values).forEach(([name, value]) => {
    root.querySelectorAll(`input[type="hidden"][name="${name}"]`).forEach((field) => { field.value = value; });
  });
}

Decide two things with the client before building it, and write them down:

  • First touch or last touch. This version keeps the latest campaign: a new set of parameters replaces the old. For first touch, write only when nothing is stored.
  • Consent. Keeping a campaign identifier in the browser for months is tracking under the European and UK rules, and may be elsewhere. Where consent is required, run captureCampaign() only after the visitor has given it. A production version wrote a consent flag of "Unchanged" into every form whatever the visitor had chosen, which records nothing a regulator would accept.

Running them

JavaScript
// src/js/forms/index.js
import { enhancePhones } from './phone';
import { fillTimezone, prefillFromLink, prefillMember, captureCampaign, fillCampaign } from './prefill';

const campaign = captureCampaign();   // stores the campaign when this URL carries one

function run() {
  enhancePhones();
  fillTimezone();
  prefillFromLink();
  prefillMember();
  fillCampaign(campaign);
}

if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
else run();

Campaign capture has to run on the page the visitor lands on, which is rarely the form's page, so also run captureCampaign() from the site-wide script; the rest belongs with the form.

Check each helper by submitting a test entry on a development site and reading the stored answer, in the admin or through the submissions API: the phone number in E.164, the zone name, the campaign values. A helper that fills the wrong field name fails without a sound, and the first anyone hears of it is a report with an empty column.

Go deeper

Was this page helpful?

Your answer goes to the docs team.