Form builder plugin for Craft CMS 5. Built for the Elloro Craft boilerplate as a replacement for craftcms/contact-form + contact-form-extensions.
Why it exists: the stock stack silently marks every submission as spam when reCAPTCHA keys are misconfigured — visitors see success, mails never arrive, nobody notices. This plugin treats captcha config errors and spam as fundamentally different things.
- Form builder in the CP — drag & drop field cards (text, email, tel, textarea, select, radio, checkbox, checkboxes, number, date, url, hidden, consent, file upload, plus heading/paragraph layout blocks) with per-field width (full/half), placeholder, description, default value, admin label and custom validation message, auto-suggested handles, and per-form notification recipients and subjects. Forms are content (database), so they're editable on production where
allowAdminChangesis off — and exportable/importable as JSON (CP or console) and duplicable. - Conditional fields — show/hide rules (
all/anygroups; is / is not / contains / is empty / is not empty) evaluated by strictly matched JS and PHP halves; a hidden field is never required, its posted values are discarded server-side, and a broken rule fails open instead of locking visitors out. - File uploads — single-file fields upload into a configurable volume (per-form subfolder) with extension/size validation before any asset is created; files are cleaned up on hard delete and by retention.
- Stored submissions — every submission (including spam-flagged ones) is saved as an element, browsable per form in the CP, searchable, with statuses Sent/Spam/Failed and a per-form reference number (#1, #2, …). Values are keyed by field uid with a submit-time snapshot of the form definition, so renaming a field never orphans historical data. Mail send failures are recorded on the submission (status Failed) — nothing is ever lost. Identical double submits within 5 minutes are deduped.
- Multi-provider captcha — Google reCAPTCHA v2/v3/Enterprise or Cloudflare Turnstile, with honest verdicts:
- pass → submission goes through (v3/Enterprise score persisted on the submission)
- spam (low score, invalid token) → stored + flagged, visitor sees success (no bot tip-off), no email sent. Scores below the reject threshold are definite bots: rejected outright, not stored.
- error (bad keys, provider down) → never treated as spam. Fail-open (default): submission accepted + flagged with the reason, warning logged, note in the notification email. Fail-closed: visitor sees a real error.
- Token binding — v3/Enterprise tokens are checked against the per-form action and the hostname they were minted on, so a token farmed elsewhere cannot be replayed here.
- Minimum fill time — submissions arriving faster than a human could type (default 3s, hashed render timestamp) are rejected before any captcha call is made.
- Sender blocklist — for human-driven spam a captcha score cannot catch: match a full address,
@domainsuffix, local-part prefix or IP prefix. Matches are stored as reviewable spam. - Honeypot — hidden field, toggleable, configurable name.
- Throttle — submissions per IP + form are rate-limited (default 5 per 60s); a hammering bot is rejected before any captcha call.
- Storage switches + retention — mail-only mode (
saveSubmissionsoff), drop spam instead of storing it (saveSpamSubmissionsoff), and auto-prune stored submissions after N days (retentionDays, runs with Craft's GC orphp craft recranet-forms/gc/prune). Retention is overridable per form, with a choice between delete and anonymize (row and reference number survive for statistics, all personal data — values, token, uploads — is blanked). Match retention to the site's privacy statement. - GDPR self-service — the
{selfServiceUrl}merge tag links the submitter to a tokenized page where they can view and permanently delete their own submission, no login needed. - Per-site translations — form wording is translatable per site from the CP, stored as content; optional AI fill via
recranet/craft-ai-translator. See Multi-site below. - False-positive recovery — "Not spam" and "Resend notification" as bulk element actions and detail-view buttons; the original spam reason is kept as an audit trail.
- Granular permissions — manage forms, view submissions and delete submissions are separate permissions.
- Deploy health check —
php craft recranet-forms/captcha/checkcatches missing keys and provider connectivity problems, exits non-zero. Add it to the deploy flow. Note: Google validates tokens before secrets, so a wrong-but-present secret only surfaces at runtime — where it is reported as a config error (visible in the CP and the notification email), not as spam. - Email / SMTP test utility — CP → Utilities → Email / SMTP test verifies the SMTP connection and sends a test mail, surfacing the full transport errors Craft's mailer swallows. Works with
allowAdminChangesdisabled. - CSV export — the submissions index export includes "Submissions (expanded fields)": every form field becomes its own column.
- Notifications + confirmations — HTML emails, reply-to set to the submitter, optional confirmation email. Templates overridable per project.
- Multi-locale — front-end strings translated for nl/en/de/fr/es/it.
composer require recranet/craft-forms
php craft plugin/install recranet-formsSet the keys in .env:
RECAPTCHA_SITE_KEY=...
RECAPTCHA_SECRET_KEY=...
Render a form anywhere in Twig:
{{ craft.recranetForms.render('contact', {
class: 'my-form',
buttonLabel: 'button.send'|t,
redirect: 'contact?submitted=true'
}) }}Create templates/recranet-forms/form.twig in the project to fully own the markup. The template receives form, options, formErrors, formContent, erroredFormHandle. Required inputs:
{{ csrfInput() }}
{{ actionInput('recranet-forms/submissions/submit') }}
{{ hiddenInput('formHandle', form.handle|hash) }}
{# field inputs as fields[<handle>] #}
{{ craft.recranetForms.captchaTag(form.handle) }}captchaTag() renders the hashed timestamp field (submit-timing check) plus the configured captcha widget, its token bound to the given action name — give every form its own. The old recaptchaTag() still works as a deprecated alias.
Email templates are overridable at templates/recranet-forms/_emails/notification.twig and confirmation.twig; values come from submission.values (submit-time snapshot) or submission.value('handle').
Besides the Twig call, forms can be dropped into content: create a field of type Form (Settings → Fields), add it to an entry type / Matrix block / CKEditor entry, and pick the form. The field stores the form's uid, so renaming its handle never breaks the reference.
{{ entry.contactForm }} {# renders the picked form #}
{{ entry.contactForm.form.name }} {# the Form model #}
{{ entry.contactForm.render({ class: 'x' }) }}An empty field renders nothing; so does a field pointing at a form that has since been deleted.
The form edit screen has preview panels: Fields → Preview form renders the front-end template, and the Notification/Confirmation tabs preview the emails with sample values (subject included, merge tags resolved). Previews go through the same template resolution as a real render or send, so "Default template" is something you can look at before overriding it. Nothing is sent or stored.
Plugin settings (CP → Settings → Recranet Forms, stored in project config):
| Setting | Default | Notes |
|---|---|---|
| Captcha provider | reCAPTCHA v3 | none / v2 / v3 / Enterprise / Turnstile |
| Site/secret key | $RECAPTCHA_SITE_KEY / $RECAPTCHA_SECRET_KEY |
env vars |
| Score threshold | 0.5 | below = spam (stored, reviewable) |
| Reject threshold | 0.3 | below = definite bot (rejected, not stored) |
| Fail open | on | what happens when verification itself errors |
| Verify token hostname | on | rejects tokens minted on other hostnames |
| Honeypot | on, rf_website |
|
| Minimum submit time | 3s | 0 disables |
| Throttle | 5 per 60s | per IP + form; 0 disables |
| Sender blocklist | empty | address / @domain / local-part / IP prefix |
| Notification language | submission's site | or always the primary site |
| Save submissions | on | off = mail-only mode |
| Save spam submissions | on | off = flagged spam is dropped |
| Retention (days) | 0 (keep forever) | prunes with Craft GC; per-form override + delete/anonymize mode on the form |
| Upload volume | empty | required for file fields; per-form subfolder |
| Max upload size / extensions | 8 MB, pdf,jpg,jpeg,png,doc,docx |
conservative allowlist by default |
Editors type form text themselves, so translations are content too: they live in the database, per site, next to the source form — no developer and no deploy to change a wording.
Open a form and pick a site from the breadcrumb site menu (the same switcher entries use). The primary site holds the source form; every other site shows a source → translation table for the strings visitors and recipients read: field labels, placeholders, descriptions and consent text, option labels, custom validation messages, the form name, and the notification and confirmation subjects and body text. Leave a translation empty and that string falls back to the source, so a half-translated form still renders completely.
Structure — field types, handles, widths, conditional rules — is deliberately shared with the source form. A translated form stays the same form, so submissions from different sites remain comparable, and option values stored with a submission stay in the source language (only their labels translate).
With recranet/craft-ai-translator installed and configured, each translation site gets a Translate with AI button that fills the missing strings using that plugin's provider — so the project's glossary and tone-of-voice settings apply, and merge tags like {naam} are left alone. Translations an editor already wrote are never overwritten. Without the plugin the button is simply absent and translating stays manual.
Emails render in the language of the site the submission was made on, whoever triggers the send. The owner notification can be pinned to the primary site's language instead — see Notification language under Settings → Storage. The plugin's own strings (validation messages, buttons, the whole control panel) ship translated for nl/en/de/fr/es/it in src/translations/.