yannkost/craft-easy-form
Composer 安装命令:
composer require yannkost/craft-easy-form
包简介
Make simple forms and display/export submissions
README 文档
README
A Craft CMS plugin for building forms and managing submissions, designed for flexible frontend modeling and high submission volumes.
Forms and submissions are stored in custom database tables (easyform_forms,
easyform_submissions) — not as Craft elements — to avoid element/content
table overhead at scale. Form definitions and submission payloads are stored as
JSON, with a deliberate contract around the payload so flexible data stays
operationally safe.
The control panel — submissions list/detail, forms index, the form builder, and settings (tabbed by type) — uses a consistent, refined card-based UI.
Requirements
- Craft CMS 5.8.0 or later
- PHP 8.2 or later
Installation
composer require yannkost/craft-easy-form ./craft plugin/install easy-form
Fresh installs create the final schema directly. Existing installs are migrated
automatically (legacy formbuilder_* tables are renamed and upgraded).
Rendering a form
Use the easyForm() Twig function with a form handle, id, or Form model:
{{ easyForm('contact') }}
{# with options #}
{{ easyForm('contact', { submitButtonText: 'Send', class: 'easy-form my-form' }) }}
Options: class, submitButtonText, submitButtonClass, prevButtonText,
nextButtonText, disableFrontendValidation, and includeStyles.
Default styles
A bundled stylesheet (form-render.css) is loaded by default. Turn it off
globally in Settings → General → Include Default Form Styles to style forms
with your own CSS, or per render: {{ easyForm('contact', { includeStyles: false }) }}.
The JavaScript (AJAX submit + events) always loads.
Styling hooks (validation messages & required marks)
Validation messages and required indicators carry stable classes so you can restyle — or hide — them with your own CSS. There is no setting to suppress message text: hide it with a style rule instead.
| Element | Class(es) |
|---|---|
| Any validation message (both of the two below) | .easy-form-error-message |
| Live (client-side) field message | .field-error |
| Server/AJAX field message | .easy-form-field-error |
| Invalid field (the input itself) | .error |
| Form-level banner (after submit) | .easy-form-message (.error / .success) |
Required indicator (the *) |
.required |
Hide all field-level validation text but keep the error styling (e.g. the red
outline on .error):
.easy-form-error-message { display: none; }
Hide the required asterisks:
.easy-form-field .required { display: none; }
Form behavior (per-form toggles)
Each form's Behavior tab has:
- Redirect URL / Hide Form on Success — what happens after a submit.
- Pre-fill from URL — when on, fields are pre-filled from matching query
params on page load, e.g.
…/contact?email=jane@acme.com&name=Janefills theemailandnamefields (matched by handle). Off by default; only forms you opt in are auto-filled. The form is taggeddata-url-prefill="true"and the JS fills text inputs, textareas, selects, radios and checkboxes. - Show Step Indicator — on multi-page forms, renders a numbered
.easy-form-stepsindicator above the form; the current step gets.is-activeand completed steps.is-complete.
Presentational fields
Alongside input fields, the builder offers render-only fields that carry no value and are never validated or stored:
- Heading — an
<h2>/<h3>/<h4>(text taken from the field label, so it's translatable on the Labels tab). - Divider — a horizontal rule.
- Callout — an info / warning / success box with an optional hex accent color.
Building your own markup
To render a form yourself, get the layout instead of the HTML with
easyFormLayout() — it returns the Form model, so you can loop it:
{% set form = easyFormLayout('contact') %}
{% for page in form.pages %}
{% for row in page.rows %}
{% for field in row.fields %}
{{ field.label }} ({{ field.type }}) → name="fields[{{ field.handle }}]"
{% endfor %}
{% endfor %}
{% endfor %}
Forms post to easy-form/submissions/submit with field values namespaced under
fields[...]:
<input type="email" name="fields[email]">
Selecting a form in an entry (the Form field)
Easy Form registers a Form custom field. Add it to any element's field layout — entries, categories, users, assets, Matrix/CKEditor blocks — and authors get a dropdown to pick one of your forms.
In templates the field returns the selected Form model, which you can hand
straight to easyForm() (no need to dig out the handle):
{{ easyForm(entry.myFormField) }}
When no form is selected — or the chosen form was later deleted — the field is
null and easyForm() renders nothing, so it's safe to call unconditionally.
Add a guard only if surrounding markup should depend on it:
{% if entry.myFormField %}
<section class="contact">
{{ easyForm(entry.myFormField) }}
</section>
{% endif %}
The selected form's name shows in element index columns and cards, and is included in element search keywords.
Conditional visibility
Pages, rows, and fields can each carry a conditions block that shows/hides
them based on another field's value:
{ "action": "show", "logic": "all",
"rules": [ { "field": "type", "operator": "equals", "value": "business" } ] }
Operators: equals, notEquals, contains, notContains, isEmpty,
isNotEmpty. logic is all (AND) or any (OR) — use multiple rules with
any for OR. Rules compare against the raw submitted value for the current
site, so authors keep option values aligned (or add per-site rules).
Visibility is hierarchical: a hidden page hides its rows and fields; a hidden row hides its fields. Hidden fields are:
- excluded from frontend validation and from the submitted payload (their DOM value is kept, so re-showing restores it);
- re-evaluated and discarded server-side as defense in depth.
Configure them in the builder: a field's Conditions tab, or a row's Conditions tab.
JavaScript events & hooks
form-submit.js dispatches easyform:* CustomEvents on the <form> element
at each step. They bubble, so you can listen on the form or on document:
document.addEventListener('easyform:success', (e) => { // e.target is the form; e.detail.response is the server JSON gtag('event', 'form_submit', { form: e.detail.formHandle }); });
| Event | When | e.detail |
Cancelable |
|---|---|---|---|
easyform:init |
each form initialized | — | — |
easyform:beforevalidate |
before client validation | — | — |
easyform:invalid |
client validation failed | errors[] ({field, message}) |
— |
easyform:beforesubmit |
validation passed, before POST | — | ✅ preventDefault() stops it |
easyform:submit |
request about to be sent | formData |
— |
easyform:success |
server accepted | response (parsed JSON) |
— |
easyform:error |
server rejected / network error | response / error |
— |
easyform:pagechange |
multi-page step changed | from, to, total |
— |
Every detail also carries form and formHandle. The native reset event
fires after a successful (non-hidden) submit, when the form is cleared.
A callback registry is also available for backward compatibility
(window.registerFormCallback(handle, 'init'|'beforeSubmit', fn); beforeSubmit
gets a validation helper and must return true to proceed), but the events above
are the recommended API.
Frontend fields (the allowlist)
Beyond the fields created in the form builder, a form can declare frontend fields — handles that frontend templates are allowed to submit (e.g. a dynamically-rendered list of entry checkboxes, tracking/UTM data). This keeps frontend freedom while rejecting arbitrary/tampered keys.
Each form layout carries an extraFieldPolicy:
| Policy | Behavior |
|---|---|
strict |
Only form-builder fields are accepted |
allowListed |
Form-builder fields + declared frontendFields (default) |
open |
All submitted fields accepted, subject to global safety limits |
Declared frontend fields are coerced to one of a few broad primitive types —
string, number, boolean, array — and may set maxItems / maxLength.
For structured data, JSON.stringify() it client-side into a string (a
string field also auto-encodes any non-scalar value to JSON, so nothing is
lost). They intentionally do not model element relations
(entry/asset IDs): the plugin can't guarantee those elements exist, so values
are stored as plain scalars/arrays and left for the consumer to interpret.
Each frontend field also has a label (with optional per-site overrides) used in
the CP, exports and email notifications.
Stored submission shape
Submissions are stored canonically:
{
"schemaVersion": 1,
"values": { "email": "jane@example.com", "message": "Hi" },
"frontend": { "selectedProducts": [12, 18, 44] },
"meta": {
"formSchemaVersion": 3,
"knownFieldHandles": ["email", "message"],
"frontendFieldHandles": ["selectedProducts"],
"visibleFieldHandles": ["email", "message"],
"unknownFieldPolicy": "allowListed"
}
}
Common metadata is also promoted to real columns (primaryEmail, primaryName,
source) via the form's promotedFields map, plus a fieldSnapshot so old
submissions remain readable after a form changes or is deleted.
Exports
The Exports screen (its own CP nav item) lists every form. Pick a form and a modal lets you filter by site, status, and date range, then either download the matching submissions as CSV or delete them — delete shows the matching count and an "are you sure" confirmation, and is permanent. The Submissions screen also has an inline Export CSV scoped to the current filter.
Exports are always queued — triggering one enqueues a background job and
lands you on a "preparing → ready" status page that polls and auto-downloads when
the file is built. Endpoints (driven by the UI; all require
easy-form:exportSubmissions):
POST /admin/easy-form/submissions/export # enqueue → { token, statusUrl }
GET /admin/easy-form/submissions/export-check?key=<token> # { ready, downloadUrl }
GET /admin/easy-form/submissions/export-download?key=<token> # streams the CSV
The job reads rows in batches without hydrating models, so large datasets do not
exhaust memory. Header order follows the form schema, with a trailing Extra
column for any non-schema handles. The finished file is written to
storage/runtime/easy-form-exports/ (not web-accessible), keyed by a one-time
token and garbage-collected after 24h.
Spam protection
- Honeypot — every form includes a hidden honeypot; a filled value is silently treated as spam.
- Blocked keywords — set newline/comma-separated keywords in Settings → Email Validation → Blocked Keywords. A submission whose field values contain any of them (case-insensitive) is silently rejected as spam (global).
- Blocked email domains and per-form CAPTCHA (Turnstile / reCAPTCHA
v2 / v3) are also available. CAPTCHA transport failures fail open by
default; set
captchaFailOpen→ false to reject instead during outages. - Rate limit — each form's Behavior tab has a per-IP Rate Limit / Window (0 = off): at most N submissions from one IP per window (HTTP 429 when exceeded). This is the anonymous complement to the logged-in Max Submissions Per User.
Spam submissions are discarded unless the form opts to save spam submissions.
Notifications
Per notification you can set recipients, subject, sender, Reply-To, and
CC / BCC. CC and BCC accept comma-separated addresses and the same
{fieldHandle} placeholder as recipients (resolved to a submitted email).
- Conditional sending — a notification can be limited to submissions that match (or don't match) a set of field rules, reusing the same condition engine as conditional fields. "Always send" is the default.
- File attachments — toggle Attach uploaded files to attach the submission's uploads to the email. Files larger than Settings → File Uploads → Max Attachment Size are skipped (the submission keeps the file).
Webhooks
Each form can POST every new (non-spam) submission to a Webhook URL as JSON, out of the request cycle (queued, with retry). Two payload modes:
- Full —
{ handle, formId, submissionId, dateCreated, siteId, values, frontend, meta }. - Data only — a flat
{ handle: value }map of field values.
Authentication is added in PHP, not the CP — listen for
Webhooks::EVENT_BEFORE_SEND to add headers, mutate the payload, or cancel:
use yii\base\Event; use yannkost\easyform\services\Webhooks; use yannkost\easyform\events\WebhookEvent; Event::on(Webhooks::class, Webhooks::EVENT_BEFORE_SEND, function (WebhookEvent $e) { $e->headers['Authorization'] = 'Bearer ' . App::env('MY_WEBHOOK_TOKEN'); // $e->isValid = false; // cancel this webhook });
The URL is validated (http/https) on save, and an SSRF guard refuses to post
to private / reserved / loopback hosts. To allow an internal endpoint, set
allowPrivateWebhookHosts → true in settings.
Server-side events (extending submissions)
Submissions fire PHP events you can hook from your own module/plugin with
Event::on(...). There are three, with deliberately different contracts:
SubmissionsController::EVENT_BEFORE_VALIDATE — transform / inject data
Runs before validation. Changes you make to $event->submissionData are
applied and then flow through condition evaluation, validation, and
canonicalization (allow-list, size caps, control-char stripping) — i.e. they're
treated exactly like submitted data. This is the safe place to normalize,
default, enrich, or inject a server-computed value.
To store a value that isn't a builder field, declare it as a frontend (allow-listed) field on the form, then set it here:
use yii\base\Event; use yannkost\easyform\controllers\SubmissionsController; use yannkost\easyform\events\SubmissionValidationEvent; Event::on( SubmissionsController::class, SubmissionsController::EVENT_BEFORE_VALIDATE, function (SubmissionValidationEvent $e) { // `serverTier` is declared as a frontend field on the form $e->submissionData['serverTier'] = lookupTier($e->submissionData['email'] ?? ''); // …or reject the whole submission: // $e->isValid = false; $e->message = 'Nope'; } );
SubmissionsController::EVENT_AFTER_VALIDATE — inspect / cancel only
Runs after validation. Use it to inspect the validated data and optionally
cancel ($e->isValid = false). Its submissionData is read-only — mutating
it has no effect (that would bypass validation and desync conditional
visibility). To change data, use beforeValidate.
Submissions::EVENT_BEFORE_SAVE_SUBMISSION — full control
Runs with the fully-built Submission model, just before the DB write — past
the allow-list/sanitization pipeline. Mutating $event->submission (e.g.
setData(), promoted columns) persists. This is the explicit "I own the
final shape" hook for metadata or values you don't want treated as form fields.
There's also EVENT_AFTER_SAVE_SUBMISSION for side effects.
Privacy & retention
-
IP address and user agent are only stored when Store IP addresses is enabled in settings.
-
Configure Submission retention days and prune old submissions:
php craft easy-form/submissions/prune # uses configured retention php craft easy-form/submissions/prune --days=90 # override php craft easy-form/submissions/prune --dry-run # preview
-
Deletion is permanent. Deleting submissions (CP, exports-page delete, prune, GDPR erase) is a hard delete — there is no trash/restore.
Operational notes
- Exports are always queued and built off the request cycle (batched reads, no model hydration), so they scale to large datasets by default. A queue runner must be active for the file to be produced.
- GDPR email lookup matches the promoted
primaryEmailcolumn plus a JSONLIKEover the payload — portable, but consider a normalized index for high-volume privacy workflows.
Debug logging
Verbose logging (payloads, recipients, layouts) is off by default. Enable it only when debugging:
EASY_FORM_DEBUG=true
Warnings and errors are always logged to storage/logs/easy-form.log.
Tests
Unit tests cover the core logic (schema normalization, condition evaluation, the submission data contract) and run with the PHPUnit phar:
php phpunit.phar
CI lints src/ and runs the suite on PHP 8.2–8.4. The Playwright end-to-end
suite and the dev data seeders are documented in CONTRIBUTING.md.
统计信息
- 总下载量: 0
- 月度下载量: 0
- 日度下载量: 0
- 收藏数: 0
- 点击次数: 1
- 依赖项目数: 0
- 推荐数: 0
其他信息
- 授权协议: proprietary
- 更新时间: 2026-07-05