Building Form Components
Build a form suite with a parent form, field children, and a secure PHP backend
Forms pull together everything the other guides cover in isolation: a parent/child suite, native browser validation, an Alpine-driven submit flow, and a PHP endpoint that must treat every byte it receives as hostile. This guide assembles all of it into one complete, working architecture — a parent form component, independent field children, and a backend that validates, filters spam, sends mail, and forwards to webhooks.
Unlike the other guides in this section, this one doesn't dissect a core-pack component — the open-source Core Pack has no form suite. Instead it presents a reference architecture: freshly written com.example.* code that scales up the contact-form example from the Backend reference by combining the parent/child contract from Component Suites with the server-side patterns from Server-Side PHP Components.
The Architecture
One component per responsibility. The parent owns the <form> element, the submit flow, and the backend endpoint; each field is an independent child the user drops into the parent's dropzone:
com.example.formpack/
├── components/
│ ├── com.example.form/ # the parent
│ │ ├── info.json # requiresPhp: true
│ │ ├── properties.json # submitLabel, notificationEmail, requiredFields…
│ │ ├── hooks.js
│ │ └── templates/
│ │ ├── index.html # <form>, fields dropzone, honeypot, button, status
│ │ ├── alpine.html # the submit factory (portal)
│ │ └── backend/
│ │ └── submit.php # validation, spam checks, mail, webhook
│ ├── com.example.textField/ # a field child: <label> + <input>
│ │ ├── properties.json
│ │ ├── hooks.js
│ │ └── templates/
│ │ └── index.html
│ └── com.example.selectField/ # same contract, renders a <select>
└── shared/
└── assets/ # optional: an SMTP library (see Sending Mail)You could build this as one mega-component whose inspector defines every field — but you'd be rebuilding the editor inside an inspector. The number of fields is unpredictable, each field needs its own settings, and a plain @dropzone filling is inert — the moment a field needs an inspector and validation of its own, it has to be a component. These are exactly the split criteria from Component Suites.
What makes a form suite unusually easy to wire is that the parent never enumerates its children. The browser does it: any control inside a <form> that carries a name attribute is serialised into FormData automatically, and PHP receives the same set as $_POST. The suite contract collapses to "render a named input inside the form" — the DOM-ancestry channel from the suites guide, with native form serialisation doing the transport.
The Parent: com.example.form
The parent's hooks resolve the mode, its instance id, and — crucially — pass rw.node through so the template can point the form at the backend folder via rw.node.backendPath, the same pattern the Backend reference uses:
The template renders the form element itself, the fields dropzone, a hidden honeypot and metadata input, the submit button, and two status regions:
Three details to note. The action and method attributes make the form work even before (or without) JavaScript — the Alpine layer is an enhancement, not a requirement. The data-form-id attribute is the parent's half of the DOM-ancestry contract, there for any child that needs to discover its form at runtime. And the @if(!edit) block around @submit.prevent is the edit-mode gate — more on that below. (@submit.prevent itself is Alpine shorthand, not an Elements directive; it passes through the template engine untouched.)
The Field Contract
A field child can be anything, from any pack, as long as it honours four rules:
Render exactly one form control with a
nameattribute. That name is the key the backend receives in$_POST— it's the entire data contract.Give the control a unique
idand point a<label for="…">at it. Derive the id from the node id so two instances never collide.Express validation as native attributes —
required,type="email",pattern— so the browser, the parent's Alpine factory, and assistive technology all see the same rules.Stay out of the reserved namespace. Never start a field name with
_(the parent's metadata prefix) and never reuse the honeypot's name.
Here is com.example.textField in full — properties, hooks, template:
The Type select is validation-as-property: choosing Email emits type="email", and the browser validates the format for free. To support custom formats, add a pattern text property and emit it the same way as required — an @if(hasPattern) wrapping a pattern="{{pattern}}" attribute (precompute hasPattern in hooks; @if takes a single condition). A com.example.selectField follows the identical contract with a <select> element — build its <option> list from a collection, as shown in Collection-Driven Dropzones. And since a field dropped outside any form silently does nothing, give children the edit-mode orphan banner from Component Suites.
Submission UX with Alpine
The parent's Alpine factory lives in templates/alpine.html — a root-level template, so Elements processes it automatically alongside index.html. It drives the whole visitor-facing flow: validate, disable the button, fetch() the backend, and surface the result:
submitting disables the button (:disabled) and swaps its label; status reveals exactly one of the two status regions. On failure the form keeps the visitor's input — only success resets it.
One thing this factory deliberately does not do is run on the canvas: the @if(!edit) in the parent template strips @submit.prevent in edit mode, so clicking Send while editing never fires a fetch() at a backend that doesn't exist yet. Everything visual — the button, the fields, the layout — stays fully styleable on the canvas. That's the gate behaviour, keep styling visible discipline from Designing the Edit-Mode Experience.
The Backend: submit.php
The endpoint extends the contact-form example from the Backend reference into something production-shaped: method check, honeypot, server-side required fields, sanitisation, and JSON responses with meaningful status codes. notificationEmail and requiredFields (a comma-separated list of field names) are text properties on the parent — publish time can't know which children the user dropped in, so the user declares which names the server must enforce:
Everything arriving in $_POST is untrusted request-time data, so it's escaped with htmlspecialchars(…, ENT_QUOTES, 'UTF-8') before it goes anywhere near an email body or a webhook payload — the same rule as A Note on Escaping. The status codes matter too: the Alpine factory treats any non-2xx response as failure, so 422 for validation problems and 405 for stray GETs give you correct front-end behaviour with no extra client code.
Spam Protection
The Honeypot
The honeypot is already fully wired above; here's why it works. The parent renders a text input named website inside a container hidden with class="hidden" and aria-hidden="true", with tabindex="-1" and autocomplete="off" so neither keyboard users nor browser autofill ever touch it. Humans can't see the field, so it always arrives empty; unsophisticated bots fill every input they find, so a non-empty value is a reliable bot signal. The backend then claims success rather than returning an error — a rejection teaches a bot author what to fix, while a fake success ends the conversation. Pick a trap name that looks worth filling (website, company) rather than one that advertises itself, and make sure no real field child ever uses it.
CAPTCHA Services
For heavier abuse you can layer a CAPTCHA service on top. Whatever provider you choose, the shape is always the same round-trip:
A widget script runs in the browser, and once the challenge passes it writes a token into a hidden input inside your form —
FormDatacarries it to your backend automatically.submit.phpreads the token and makes a server-to-server verification call to the provider, sending the token plus your secret key.Only if the provider confirms the token do you process the submission; otherwise return
422like any other validation failure.
The provider gives you two keys, and they must be handled differently. The site key is designed to be public — it ends up in your rendered HTML no matter what — so a text property on the form component is the right home for it. The secret key is another matter:
Property values are interpolated into published files at publish time, so anything a user types into the inspector ships with the site. Only publishable (site) keys belong in component properties — never secret keys. Instruct users to put the secret key directly in the backend file on the server, or better, in the hosting environment (read it with getenv()), where it never passes through the project file or the publish pipeline.
Sending Mail
The mail() call above is the honest weak point of the example: it hands the message to whatever local mail transport the host provides, with no SMTP authentication and often a misconfigured envelope sender — so on real-world shared hosting, messages regularly land in spam or vanish, and some hosts disable mail() outright. The robust upgrade is an SMTP library authenticating against a real mailbox. Don't put a multi-file library inside templates/backend/ — Elements watches every backend file for changes, which hurts performance. Ship it in your pack's shared assets and require_once it from submit.php via siteAssetPath, exactly as the Backend reference shows for large PHP libraries. The trade-offs are the same as the vendoring decision table in Integrating JavaScript Libraries: one shared copy for the whole pack, referenced from every component that needs it.
Forwarding to a Webhook
Email is for humans; automations want JSON. Forwarding the sanitised $data array to a user-configured webhook URL (a webhookUrl text property) takes one curl call at the end of submit.php, after the mail step:
Note the snippet ignores the response and never fails the visitor's submission over a webhook problem — the email is the primary record. That's also the caveat: this is fire-once with no retry. If the endpoint is down for ten seconds, that event is gone. For genuinely mission-critical integrations, point the webhook at a service that queues and retries on its own, rather than teaching submit.php to persist state.
requiresPhp and the Suite
Declare requiresPhp: true in com.example.form's info.json. Strictly speaking the endpoint runs as its own request — the browser fetches submit.php directly — but the flag declares the suite's real dependency on a PHP-capable host, and the moment the form grows any server-rendered markup on the page itself (a CSRF token, a server-rendered thank-you state) the page must publish as .php. The children need nothing: per the info.json reference, the flag isn't needed for child components that only ever appear nested inside a component that already requires PHP. But inheritance doesn't travel — a field child that can also be placed standalone must declare the flag itself. The full rules, including that standalone caveat worked through, are in Server-Side PHP Components.
Related Documentation
Backend Directory — deployment structure,
node.backendPath, and the seed contact-form exampleComponent Suites & Cross-Component Communication — when to split, and the parent/child contract
Server-Side PHP Components —
requiresPhp, escaping, and request-time PHPDesigning the Edit-Mode Experience — gating behaviour on the canvas
@dropzone — the directive behind the fields area
Last updated
Was this helpful?

