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 06 · Acting on a submission

Jobs, notifications and confirmations

Starting a job when a form submission completes and what the job receives, then the two messages almost every form needs: a notification to staff, routed by an answer if need be, and a confirmation to the person who wrote in that cannot be turned into a spam relay.

Who this is for. The developer who wires up what happens after a form is submitted. After this page you can start a job on a completed submission, read its answers safely, send staff a notification routed by an answer, and send the person who wrote in a confirmation that cannot be abused.

When a form is submitted, the platform stores the answers and shows the result step or redirects. That is all it does. Every email, record, integration and receipt that follows is a job you build. Jobs were called hooks until the rename, and the admin lists them under Customization › Orchestration; Automation, jobs and integration explains them, and this page does not repeat it. The next pages cover creating records, pushing to a CRM and taking payments.

Starting a job on a submission

Give the job an entity event trigger on form submissions, method Completed, and choose the form in the trigger's Parent Form field: a submission's parent is the form it belongs to. Without a parent, the job runs for every form on the site.

Use Completed, not CreateRecord. A submission is created when its first step is saved, so on a multi-step form CreateRecord fires before the visitor has answered most of it, and it also fires for submissions that are never finished. Completed fires once, when the submission is first complete: after the last step on a page-per-step form, after the single request of a JS form, and, for a donation confirmed later by PayPal, when that confirmation arrives. Editing a submission afterwards does not fire it again. See Job event reference.

Keep the job asynchronous, the default for a new job. The visitor's request then ends as soon as the submission is saved, and nothing the job does (a slow mail provider, a CRM that is down) can delay or fail their submission.

What the job receives

An asynchronous job runs with the API's models, so $event->getRecord() is the submission as the API represents it. Its answers are data, keyed by element name: a string for a single answer, a list for a checkbox list, a multi-select, a range or a file element (the value types are listed on Reading and exporting submissions). data exists only on that representation, which is one more reason to keep the job asynchronous. $event->getRecordId() is the submission's id.

Template code
{$submission = $event->getRecord()}
{$answers = $submission->data}
{$formName = $submission->getForm()->getName()}
{$email = trim($answers['email']|default:'')}
{$extras = $answers['extras']|default:[]}
{if is_array($extras)}{$extras = implode(', ', $extras)}{/if}
{% set submission = event.getRecord() %}
{% set answers = submission.data %}
{% set formName = submission.getForm().getName() %}
{% set email = answers.email|default('')|trim %}
{% set extras = answers.extras|default([]) %}
{% if extras is iterable %}{% set extras = extras|join(', ') %}{% endif %}

Three things to know before using the answers:

  • A field the visitor skipped may be missing or empty, and a field added after the submission was made is missing from it. Give every read a default.
  • Answers are whatever the visitor sent. Treat answers as visitor input: check an email answer with a regex on the element, and a select's answer against its options, before acting on it (see Validation rules and error handling). Escape every answer you print, and never use one to choose something sensitive without checking it against a fixed list.
  • Read by name, as the field was named when the submission was made. A field renamed on a live form keeps its old name on older submissions.

A submission has no layout areas of its own, so getAreaText() and the other area getters do not apply to it. The form's own layout areas are on $submission->getForm()->getActiveRevision().

Notifying staff

The common case, a Send mail action listing every answer with reply-to set to the sender, is a complete how-to: Send an email when a record is created. Two decisions to make before copying it:

  • How much of the answers belongs in an inbox. Every notification is a copy of the submission in each recipient's mailbox, kept as long as their mail is, and outside any deletion you carry out later (see Retention and privacy). For forms that collect sensitive answers, send "A new enquiry has arrived" with a link to the submission in the admin, and keep the answers there. Never include an upload's link (see File uploads).
  • Who receives it. Keep addresses in site data, never in the action, so each environment can send to its own inbox and a change of staff is not a change to the job.

Sending to a recipient chosen by an answer

An enquiry form asks Department (a select with the values sales, support and accounts), and each department wants its own enquiries. Map the answer to a site data key in the Send mail action's To field, with a default for anything else:

Template code
{$answers = $event->getRecord()->data}
{$inboxes = ['sales' => 'enquiries-sales-to', 'support' => 'enquiries-support-to', 'accounts' => 'enquiries-accounts-to']}
{$department = $answers['department']|default:''}
{$key = 'enquiries-to'}
{if isset($inboxes[$department])}{$key = $inboxes[$department]}{/if}
{$siteData->get($key)}
{% set answers = event.getRecord().data %}
{% set inboxes = {'sales': 'enquiries-sales-to', 'support': 'enquiries-support-to', 'accounts': 'enquiries-accounts-to'} %}
{% set department = answers.department|default('') %}
{{ siteData.get(inboxes[department] ?? 'enquiries-to') }}

Map from the option's value, which is what is stored, not its title. The fixed list matters: a visitor can post any value for a select, so an answer that is not one of yours falls through to the default inbox instead of choosing an address.

Confirming to the person who wrote in

A second Send mail action, to the address they gave:

  • To: {$event->getRecord()->data['email']} (Twig {{ event.getRecord().data.email }}).
  • From: an address on the client's own, authenticated domain.
  • Reply-to: the team's shared inbox, so a reply reaches people rather than a no-reply mailbox.
  • Body: a template in the repository, such as templates/hooks/emails/enquiry-received.html (the folder keeps the module's old name).

Two risks belong to this message and not to the staff notification:

  • It can be made to send your words to anyone. The visitor chooses the recipient. If the confirmation repeats their message, anyone can use the form to send their own text from the client's domain to any address: a spam relay with the client's reputation on it. Keep the confirmation fixed. Do not echo free-text answers, and treat even a name as free text. Put a captcha, with Require completed captcha ticked, on every form that sends one.
  • Guard the confirmation with a Condition that checks the address first, and put it after the actions that matter more (below), so an invalid address stops only the confirmation:
Template code
{$email = trim($event->getRecord()->data['email']|default:'')}
{if preg_match('/^[^@\s]+@[^@\s]+\.[^@\s]+$/', $email)}{$result->setMatching(true)}{/if}
{% set email = event.getRecord().data.email|default('')|trim %}
{% if email matches '/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/' %}{% do result.setMatching(true) %}{% endif %}

The body itself:

Template code templates/hooks/emails/enquiry-received.html
<!DOCTYPE html>
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333333;">
  <p>Thank you for getting in touch.</p>
  <p>We have your enquiry and will reply within two working days. Your reference is {$event->getRecordId()}.</p>
  <p>If you did not send it, you can ignore this email.</p>
</body></html>
<!DOCTYPE html>
<html><body style="font-family:Arial,sans-serif;font-size:14px;color:#333333;">
  <p>Thank you for getting in touch.</p>
  <p>We have your enquiry and will reply within two working days. Your reference is {{ event.getRecordId() }}.</p>
  <p>If you did not send it, you can ignore this email.</p>
</body></html>

It is a transactional message: what the person's own action caused, and nothing more. A "you might also like" block turns it into marketing, which needs consent (see How a job's email is sent).

Ordering the work

A failed action stops its job run, and the actions before it are not undone. So for one form:

  1. The staff notification first: it is the one that must not be lost.
  2. Records the site keeps (a person or a lead) next.
  3. The confirmation, inside its guard.
  4. Anything that talks to another system in its own job, started with a Trigger job action, so that system's outage fails only its own run (see Pushing to a CRM).

Test the whole job on a development site with a test submission, then open the run in the job's runs: each action shows its status and output. The mailer's development redirect keeps the confirmation from reaching a real address there (see Testing safely).

Go deeper

Was this page helpful?

Your answer goes to the docs team.