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

Creating records from a submission

The complete worked example of turning a submission into a person, without duplicates, step by step; then a lead, and a custom entity record created through the site's own API.

Who this is for. The developer turning form submissions into records the business works with. After this page you can build a job that files each enquiry as a person without creating duplicates, decide when a lead needs a record of its own, and create a custom entity record through the site's API.

Most client requirements for a form end in a record: the enquiry becomes a contact, the sign-up becomes a member of a list, the application becomes an entry someone works through. This page is the complete worked example of the most common one, a submission turned into a person, step by step. Then it covers a lead and a custom entity record. Starting a job on a submission and reading its answers are on Jobs, notifications and confirmations.

What a job can create

RecordHow
A person (a contact), and adding them to groupsThe Create record and Update record actions, below
A leadUsually a person in a Leads group, the same way. A custom entity record when a lead needs its own fields
A custom entity recordNot with Create record, which cannot create custom entity records. An HTTP request action to the site's own REST API, below
A page, a blog post, a FAQ, a productCreate record, as for a person; rarely what a form needs
An order, an invoice or a quoteNot from a job's record actions. A form that takes money creates its own invoice; see Taking payments

Worked example: a contact form to a person

A contact form asks for firstName, lastName, email, a message, and a single checkbox marketingConsent. The client wants everyone who writes in kept as a contact in a Leads group, and added to a Newsletter group only if they ticked the box. The same person writes in more than once.

Three facts decide the design:

  • An email address belongs to one person across the whole account. People are not per site: a person created from one site's form is the same record every site of the account sees. An address belongs to one person across the account, so the job must find the person first and update them, and only create when there is nobody to find.
  • Setting groups replaces them. An update that sends groups: [7] leaves the person in group 7 only, and removes them from every other group. Read their current groups and add to them.
  • Do not overwrite what a person already has. A member may have corrected their own name. Fill empty fields; leave the rest.

Put the two group ids in site data (leads-group-id, newsletter-group-id), so each environment uses its own.

The job

  1. In Customization › Orchestration, create a job Form: contact → person, limited to the site, asynchronous.
  2. Add the trigger: entity event, form submissions, method Completed, and the contact form in its Parent Form field.
  3. Condition no-email, template type, with a Stop action inside it, target status skipped. It ends the run for a submission without a usable address:
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 not (email matches '/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/') %}{% do result.setMatching(true) %}{% endif %}
  1. Retrieve records find-person: entity people, limit 1, criteria with the address lower-cased, so Sam@Example.com finds the person stored as sam@example.com:
Template code
{"email": "{strtolower(trim($event->getRecord()->data['email']))}"}
{"email": "{{ event.getRecord().data.email|trim|lower }}"}
  1. Condition known-person, template type: {if $runData->get('find-person')->record}{$result->setMatching(true)}{/if} (Twig {% if runData.get('find-person').record %}{% do result.setMatching(true) %}{% endif %}). Inside it, Update record update-person: source a Retrieve records result, find-person, with Record index 0. The data template follows.
  2. An Else branch on the same condition, and inside it Create record create-person, entity people. Its template follows.

Set Record index to 0, the first record the search returns. It counts from 0.

The update

Template code
{$answers = $event->getRecord()->data}
{$person = $runData->get('find-person')->record}
{$data = []}
{$firstName = trim($answers['firstName']|default:'')}
{$lastName = trim($answers['lastName']|default:'')}
{if !$person->getFirstName() && $firstName != ''}{$data['firstName'] = $firstName}{/if}
{if !$person->getLastName() && $lastName != ''}{$data['lastName'] = $lastName}{/if}
{$groups = []}
{foreach $person->getGroups() as $group}{$groups[] = $group->getId()}{/foreach}
{$wanted = [$siteData->get('leads-group-id')|intval]}
{if !empty($answers['marketingConsent'])}{$wanted[] = $siteData->get('newsletter-group-id')|intval}{/if}
{foreach $wanted as $groupId}{if !in_array($groupId, $groups)}{$groups[] = $groupId}{/if}{/foreach}
{$data['groups'] = $groups}
{$data|json_encode}
{% set answers = event.getRecord().data %}
{% set person = runData.get('find-person').record %}
{% set data = {} %}
{% set firstName = answers.firstName|default('')|trim %}
{% set lastName = answers.lastName|default('')|trim %}
{% if not person.getFirstName() and firstName != '' %}{% set data = data|merge({firstName: firstName}) %}{% endif %}
{% if not person.getLastName() and lastName != '' %}{% set data = data|merge({lastName: lastName}) %}{% endif %}
{% set groups = [] %}
{% for group in person.getGroups() %}{% set groups = groups|merge([group.getId()]) %}{% endfor %}
{% set wanted = [siteData.get('leads-group-id')|integer] %}
{% if answers.marketingConsent|default('') is not empty %}{% set wanted = wanted|merge([siteData.get('newsletter-group-id')|integer]) %}{% endif %}
{% for groupId in wanted %}{% if groupId not in groups %}{% set groups = groups|merge([groupId]) %}{% endif %}{% endfor %}
{{ data|merge({groups: groups})|json_encode }}

The create

Template code
{$answers = $event->getRecord()->data}
{$groups = [$siteData->get('leads-group-id')|intval]}
{if !empty($answers['marketingConsent'])}{$groups[] = $siteData->get('newsletter-group-id')|intval}{/if}
{$data = [
  'email' => strtolower(trim($answers['email'])),
  'firstName' => trim($answers['firstName']|default:''),
  'lastName' => trim($answers['lastName']|default:''),
  'externalId' => "form-submission:{$event->getRecordId()}",
  'groups' => $groups
]}
{$data|json_encode}
{% set answers = event.getRecord().data %}
{% set groups = [siteData.get('leads-group-id')|integer] %}
{% if answers.marketingConsent|default('') is not empty %}{% set groups = groups|merge([siteData.get('newsletter-group-id')|integer]) %}{% endif %}
{{ {
  email: answers.email|trim|lower,
  firstName: answers.firstName|default('')|trim,
  lastName: answers.lastName|default('')|trim,
  externalId: 'form-submission:' ~ event.getRecordId(),
  groups: groups
}|json_encode }}

The externalId records which submission created the person. It is searchable (people can be filtered by it), so a support question such as "where did this contact come from?" has an answer, and a re-run of the job can tell that the person already exists.

Testing it

On a development site, submit the form with a new address: a person appears in Leads. Submit again with the same address in different capitals, a different first name and the box ticked. Expect the same person, now also in Newsletter, with the first name they had and any empty last name filled, and no second person. If the person has left Leads, the update sent only the new group: check that the update template reads the current groups first.

A person is a contact, not a member: creating one gives nobody a sign-in. Members are covered in Account routes and their templates.

A lead

A lead is the same person in a Leads group, as above, when the business only needs to know who wrote in. When it needs to work the lead (a status, an owner, the enquiry text, the date of the next call), a person is the wrong record: those are not a person's fields, and one person can be several leads over the years. Make a custom entity, Lead, with those fields, and relate each lead record to the person.

A custom entity record

The Create record action cannot create custom entity records, so the job creates one the way any other client would, through the site's REST API: POST /cdna-api/custom-entities/<identifier> with the record's name and its layout areas. Two values come from site data, set separately on every environment:

  • site-url: the site's own address, such as https://www.example.com, with no trailing slash. It differs per environment, and the site record has no hostname method to build it from.
  • site-api-token: the credential of an admin user made for the integration, with permission to create those records and nothing else. OAuth is the preferred credential and an API key the older option; Authentication and authorisation explains both.

An HTTP request action create-lead, after the person actions: method POST, URL {$siteData->get('site-url')}/cdna-api/custom-entities/lead, header Authorization: Bearer {$siteData->get('site-api-token')} (in Twig, {{ siteData.get('site-url') }} and {{ siteData.get('site-api-token') }}), retries 3, body:

Template code
{$answers = $event->getRecord()->data}
{$body = [
  'name' => "Enquiry {$event->getRecordId()}",
  'areasContent' => [
    'status' => 'new',
    'enquiry' => $answers['message']|default:'',
    'sourceSubmission' => $event->getRecordId()|strval
  ]
]}
{$body|json_encode}
{% set answers = event.getRecord().data %}
{{ {
  name: 'Enquiry ' ~ event.getRecordId(),
  areasContent: {
    status: 'new',
    enquiry: answers.message|default(''),
    sourceSubmission: event.getRecordId() ~ ''
  }
}|json_encode }}

The layout area names are the ones the Lead layout defines. To relate the record to the person, add relatedRecords with the person's id, taken from find-person or create-person; the request shape is in the Custom Entities API reference. Building the body as an array and encoding it once means the message text, quotes and line breaks included, cannot break the JSON.

Because the request goes over HTTP, it can fail like any external call: give it retries, and keep it last in the job, after the person, so a failure costs only the lead record. Pushing to a CRM covers sending it again.

Go deeper

Was this page helpful?

Your answer goes to the docs team.