Version 1.0.0
This is Formable’s first release, for Craft CMS 5.10.7 or later. Pre-release builds were used to develop it, so Changed and Fixed record where 1.0.0 differs from those, for anyone who installed one. On a fresh install, everything below is simply how Formable behaves.
Added
Webhooks also send each answer as it’s stored. The payload’s
dataobject holds answers as people read them, and a newrawobject holds the stored values. So a receiver can branch on an option’s value, which stays put when an author rewords its label. The payload is described in Notifications & integrations.Conditions on an options field pick their value from a list. When a rule tests a Dropdown, Radio Buttons, Checkboxes or Multi-select field, the builder offers that field’s options instead of a text box, so a rule can no longer silently fail to match because of a typo. A rule written against an option that has since been removed keeps its value.
A
--formable-check-sizetoken sets the size of checkboxes and radio buttons. Left unset, each box matches its label’s text size. See Theming.A stability policy for people building on Formable. Every PHP class is now marked
@api(stable) or@internal(free to change in any release), and the new API stability page lists what is stable - the field, integration and preset base classes, the submission events, forms and submissions and their queries, thecraft.formableTwig variable, the front-end JavaScript entry points and DOM events, the console commands, the GraphQL schema and the form export format - along with what counts as a breaking change and how deprecations are handled. The extending guide no longer suggests custom captchas, since there is no way to register one.Form imports now check the file's format version. A file exported by a newer release of Formable than the one installed is refused with a message asking you to update, rather than imported in a shape this release would misread. Files without a format version, including hand-written ones, are read as the original format.
Integration requests can no longer reach private addresses: a webhook (or any integration) whose URL resolves to a private, loopback or link-local address - a cloud metadata endpoint, a service on localhost - is refused before anything is sent, so a user who can edit integrations but isn't an admin can't aim your server at your internal network. A site that really does need an internal receiver lists it under Integration Allowed Hosts in the plugin settings (a hostname, an address or a CIDR range per line). Redirects are no longer followed, and the webhook's Test connection now reports a blocked address straight away.
Form export/import console commands:
formable/forms/export <handle>writes a form's full definition - layout, settings, notifications and integration mappings - to a portable JSON file (provider secrets live on the global integration connections, never on the form, so nothing sensitive travels), andformable/forms/import <file>reads one back, upserting by handle: a new form is created, an existing one updated. The importer re-normalizes the layout and re-validates every part through the same services the builder's save path uses, so a hand-edited or downgraded file can't smuggle in unknown fields - and prints a create/update diff summary (pages, fields, notifications) first.--dry-runpreviews without writing; overwriting an existing form needs--force, guarding against clobbering. Meant for a dev→prod deployment workflow; works in both editions (a Pro setting an import carries is simply not enforced in Lite, exactly as a downgrade already behaves).Form starter presets: creating a form now opens on a small picker - Blank, Contact, Registration or Survey - that seeds the builder with a ready-made layout (Contact is the name/email/message layout new forms have always started from). Presets live in a registry extensible through a
Presets::EVENT_REGISTER_PRESETSevent, the same event-over-defaults shape as the field registry, so a plugin can add its own; a third-party preset can't clobber a bundled handle. The chosen preset is seeded through a newforms/createaction and only ever fills the in-memory layout - nothing is written until the form is saved.Spam count on the “Recent Form Submissions” dashboard widget: the flagged-as-spam total now shows alongside the submissions count (scoped to the widget's form, if it has one), so a form being hammered is visible at a glance.
Weekly digest email (Pro): opt in, per site, to email a summary of each form's new submissions and spam counts for the trailing 7 days to a fixed recipient list. The plugin owns the schedule (pick a weekday and hour); the send is driven off the request path by a new
formable/digest/runconsole command wired to the server's cron - run it as often as hourly and it self-gates to at most one digest a week, with a resend guard so a re-fired cron tick can't double-post. The body is a Twig template (email/digest, overridable) sent through Craft’s mailer from the Digest Sender Email and Digest Sender Name set under the plugin settings. Weeks with no activity are skipped by default, and a "Send test" button in the settings (plusformable/digest/test) sends a copy on demand. Not enforced in Lite.Submission limits (Pro): cap how many submissions a form accepts. Once it holds that many completed, non-spam submissions (unfinished and spam-flagged ones don't count) the form closes through the same
isAcceptingSubmissions()gate and closed-message path as scheduling. The cap is checked live at submit, so under concurrent load a form can slip a submission or two past the limit - a soft ceiling rather than a hard quota, which is enough for v1. Lite hides the setting behind an upgrade prompt and never enforces it.Scheduled availability (Pro): confine a form to a date window with independently optional open and close dates. A new
Form::isAcceptingSubmissions()folds the window in with the form's enabled/trashed state and is the single gate the front-end controller, thesaveFormableSubmissionmutation and the renderer all consult. A closed form - switched off, or outside its window - now renders a configurable, Twig-aware closed message in place of the fields (non-JS and AJAX alike) and turns away posts with a proper "form closed" response instead of a 404. Lite hides the schedule settings behind an upgrade prompt and never enforces the window.Headless GraphQL API (Pro): query forms and submissions and create submissions from any front end.
formableForms/formableFormexpose a form's pages, rows and fields (each field a concrete type -FormableTextField,FormableEmailField, … - behind a sharedFormableFieldInterface, so a client can branch on__typename);formableSubmissions/formableSubmissionreturn submissions with their values both as a JSON blob and per-field. ThesaveFormableSubmissionmutation runs the identical pipeline a posted form does - same validation (errors come back keyed by field handle), same spam gate (honeypot and captcha tokens accepted as arguments; a spam-caught submission reports success, exactly as the web path does), same lifecycle events, so notifications and integrations fire unchanged. Access is granted per form through Craft's GraphQL schema scopes: a read scope covers a form and its submissions, a save scope enables the mutation - and both the query fields and the resolvers enforce it, so nothing reaches a form its schema wasn't granted. None of it is registered in Lite.Integrations (Pro): forward each accepted submission to third-party services. A generic Webhook (signs its JSON payload with an HMAC header), Slack (Incoming Webhook message), Mailchimp (audience subscribe with a PUT upsert, double-opt-in optional) and HubSpot (contact create via a Private App token) ship in the launch set - all authenticated by API key/token, no OAuth to set up. Connections are configured once, admin-only, in a new Integrations settings area (with a “test connection” button and env-reference support for secrets), then switched on per form in the builder's Integrations tab with field mapping and the same conditional-logic engine used elsewhere. Delivery runs off the request path in a queue job with capped exponential-backoff retries, skips spam automatically (it rides the same
afterSubmithook notifications do), and logs every attempt - surfaced back in the builder. Third parties can register their own provider types through a registry event.Spam protection on every form: an accessible honeypot decoy, a minimum-submit-time check with a tamper-proof signed timestamp, and a JavaScript-token check - each toggled per form, applied identically to the page-reload, AJAX and (future) headless paths so none can be bypassed by posting directly.
Site-wide keyword and IP blocklists (IP wildcards supported, e.g.
203.0.113.*), applied to every form's submissions.Configurable spam action per form: keep a caught submission flagged for review, or discard it - either way the submitter sees the normal success response and no notification or integration fires.
Spam queue in the control panel: a “Spam” source on the submissions index showing why each was flagged, bulk “Mark as spam” / “Not spam” actions, and the same toggle on the submission detail view to rescue a false positive.
Captchas (Pro): reCAPTCHA v2, reCAPTCHA v3 (score-thresholded), hCaptcha and Cloudflare Turnstile, with keys entered once in the plugin settings and a provider picked per form. Rendered on the final page - including on AJAX-navigated steps - with server-side token verification that fails closed.
Submission management in the control panel: a submissions element index with a source per form, colored custom statuses (New / In Review / Approved / Rejected, filterable from the status menu), and columns for form, status, submitter, IP and dates.
Submission detail/edit view: edit a submission's values through the same field pack the site renders, change its status, and keep a running thread of notes.
Bulk actions on the index - mark as any status, delete and restore - plus CSV/JSON/XML export of submissions (a column per field) straight from the index's export button.
Edit history (Pro): every control-panel or member edit records a field-level before/after trail, shown on the submission.
Front-end submission access (Pro):
craft.formable.submissionsreturns the logged-in visitor's own submissions (ownership enforced server-side), withsubmissions/update-ownandsubmissions/delete-ownfor member self-service, which each form turns on for itself.GDPR data-retention (Pro): opt a form into auto-deleting completed submissions after a retention window, purged by Craft's garbage collection.
Per-field encryption at rest: flag a field as sensitive and its value is stored ciphered with the site security key, kept out of the submission title and search index, and deciphered only when read.
“Recent Form Submissions” dashboard widget: the latest submissions with a total count, scopeable to one form.
Multi-page forms: pages are walked one at a time with Back/Next and per-page validation, a progress indicator (numbered steps or a bar), and an optional review step that summarises every answer - with an “edit” link back to each page - before the final submit. Works with a plain page reload and over AJAX, which swaps the next step in without leaving the page.
Conditional logic on fields and whole pages: show or hide based on what's been answered, with all/any rule matching and a dozen operators. One rules engine, implemented once in PHP and once in TypeScript, kept in lockstep by a shared fixture corpus both test suites run - so a field hidden as you type is a field the server won't require, and a hidden field's value is never stored, exported or sent in a notification.
Save and resume: opt in per form to let submitters store a part-filled form and finish later from an emailed link. Incomplete submissions are kept server-side with an unguessable token, expire on their own, and are reaped by Craft's garbage collection.
Front-end form rendering via
craft.formable.renderForm('handle'), pluscraft.formable.renderField()for hand-built markup andcraft.formable.form()/craft.formable.forms()for querying.Submission pipeline (
submissions/submit): one endpoint serving both a plain form post - which redirects and re-renders errors with the submitter's own values intact - and an AJAX post that gets JSON, so neither path can reach validation the other doesn't.Submission lifecycle events (
beforeSubmit,afterSubmit,beforeSaveSubmission,afterSaveSubmission,submissionError), with thebeforeevents cancelable.File uploads into a field's configured volume and subfolder (Twig-rendered, e.g.
submissions/{{ now|date('Y-m') }}), with server-side enforcement of file count, size, allowed kinds, and a content check that rejects files whose bytes don't match their extension.Per-form template overrides: point a form at a folder in your own templates directory and override individual templates - the rest fall back to the plugin's pack, one template at a time.
Success behaviours: a flashed message (scoped per form handle, so two forms on a page don't cross wires) or a redirect, both Twig-renderable against the submission.
Optional front-end enhancement bundle (~12 kB gzipped, no framework): AJAX submit, double-submit protection, accessible error rendering with focus management, and upload previews. Forms work fully without it.
Drag-and-drop form builder in the control panel (Vue 3 + Pinia), replacing the basic form edit screen: field palette grouped by category, multi-column rows, multi-page tabs with rename/reorder, and a field settings drawer generated from each field type's settings schema.
Form settings tab: submit button label, submission method, success behaviour (message or redirect), error message, and privacy opt-ins (store submissions, collect IP, collect user agent) - plus a Pages & navigation group for the progress indicator, Next/Back labels, review page, and save-and-resume.
Conditions editor in the builder: a rule set on any field's settings drawer, and a “Page rules” panel for whole-page conditions, both writing the same schema the server and front-end evaluate.
Local autosave of in-progress edits with a restore/discard prompt, an unsaved-changes warning on navigation, and ⌘/Ctrl-S to save.
Server-side layout validation (
services/Layout): the posted layout is rebuilt from the field classes and re-validated - unknown keys are dropped, handles must be unique across the whole form, and errors come back keyed by page and field ID so the builder can attach them to the right card.Field type system:
FormFieldInterface/FormFieldbase with settings schemas, value validation, normalization and serialization.23 bundled field types - Single-line Text, Multi-line Text, Email, Number, Phone, Website, Date/Time, Hidden, Dropdown, Radio Buttons, Checkboxes, Multi-select, Agree, File Upload, Name, Address, Signature, Table, Heading, HTML Block, Section Divider, Entries and Categories.
Field registry service with a
Fields::EVENT_REGISTER_FIELD_TYPESevent so other plugins can add their own field types.Accessible front-end template pack for every field: labels and fieldset/legend grouping,
aria-describedbyinstructions and errors,aria-invalid, androle="alert"error announcements.Unknown field types degrade to a placeholder instead of breaking the form that contains them.
Form element with control panel index (search, sort, statuses, soft delete/restore).
Install migration creating all plugin tables and seeding the default submission statuses (New, In Review, Approved, Rejected).
User permissions under a “Formable” group (
formable:viewForms,formable:saveForms,formable:deleteForms, and submission equivalents).Plugin settings, stored in project config.
formable/forms listconsole command, alongside the export and import commands above.renderFormShell()gives hand-built form templates the same accessibility guarantees asrenderForm(). Until now, laying a form's fields out in your own markup withrenderField()meant reconstructing the<form>tag, CSRF input, error summary, honeypot and captcha yourself - and losing them silently if you got any of it wrong.craft.formable.renderFormShell('contact')returns ashellwith anopenand aclose: wrap your own field layout between them and you keep the error summary, focus management, honeypot and captcha with no markup to copy. A closed form is handled the same way, with no{% if %}needed around the block. See Laying out fields by hand in the templating documentation.A form can now be given your own colours, from the control panel. Settings → Formable → Appearance gains a Brand Colour, which the primary button, the focus ring and the multi-page progress bar all pick up, plus Surface, Text and Border colours and a Corner Radius (Sharp, Soft, Round) and Button Style (Filled, Outline, Soft). Until now the only way to move any of this was to hand-write CSS custom properties in your own stylesheet, which meant knowing which of around 150
--formable-*tokens to reach for; the six controls cover what most sites actually want, and the full token system is still there for everything else. The label colour on your brand is worked out for you - white or near-black, whichever is readable on the colour you picked - so a pale brand doesn't leave an invisible Submit button. The screen shows the contrast ratio for each pair WCAG sets a threshold for and tells you when one falls short, but it won't stop you saving: a colour your brand guidelines already settled isn't Formable's to veto. Surface, Text and Border only apply to a form pinned to Light or Dark, not one following the visitor's device, since that form has to stay free to repaint itself. Every field is blank by default and a form with none of them set renders byte-for-byte as it did before, emitting no extra CSS at all. On Pro, any single form can depart from the site palette: its Settings tab carries the same six controls, each starting on Use the global setting. If you already override one of these tokens from your own stylesheet, note that a colour the palette sets is applied to the form element rather than:root- so target.formable-formto override it, as Overriding a colour the palette sets in the theming documentation explains. Seedocs/theming.md.Page labels, every author-facing field string (label, instructions, and whichever of placeholder, description, sub-field labels, table columns and add-row label, or selection label a field type actually has), option labels, and nine settings messages (Success Message, Success Details, Error Message, Closed Message, and the four button labels plus the review page heading) can now be translated per site, under a new Translations tab in the form builder. Pick a site, override whichever strings need it, and leave the rest blank to keep showing the form's base text there. A site's overrides apply to the whole rendered form - the field itself, the navigation and progress indicator, the review page, server-side validation messages, and the success and error screens - including a site template that overrides one of Formable's own templates and prints a field's label directly. The layout itself stays global: this is strings only, not per-site fields, conditions or enablement. Notification emails and GraphQL stay on the base, untranslated strings. The author preview carries your unsaved translations too, with its own site selector on a multi-site Pro install. Exporting a form now carries its translations, keyed by site rather than by ID so they land correctly on an install with different site numbering. Pro. See
docs/translations.md.Single-line Text, Multi-line Text, Email Address, Website, Phone Number and Number fields now validate as you type, not just on submit. Leaving a required field blank, typing something that isn't a valid email address or URL, or going past a character limit or a Number field's min/max now shows an error immediately, on the field you were just in, instead of waiting for a round trip to the server. It's a preview only - the server still validates every submission exactly as before, so a submitter with JavaScript off, or a direct post, is checked exactly the same way it always has been. Available in both editions.
The form builder can now undo and redo. Every change to a form's layout, settings, notifications and integrations is kept on a snapshot stack - Cmd/Ctrl+Z and Cmd/Ctrl+Shift+Z (or Ctrl+Y) step back and forward through it, and the same actions sit as Undo/Redo buttons beside Preview and Save. A burst of related edits - typing a label, dragging a field into place - collapses into one step, so undo reverts what you just did rather than one keystroke at a time. History is kept for the current editing session and starts fresh on reload, the same boundary the autosave draft already has. Deleting a field or a notification, and turning an integration off, now ask for confirmation first - the field and notification prompts because that work is gone outright, the integration prompt because turning it off silently stops it receiving real submissions from that point on, even though its mapping and conditions stay saved for if you turn it back on.
Notification emails are now sent inside an email template, and each one now carries a plain-text version alongside the HTML. The template is a file, so your branding can live in one place instead of being pasted into the body of every notification: add
formable/email/notification.twigto your site's templates folder to restyle every form's emails, oremail/notification.twiginside a form's Custom Template Path to restyle just that form's. You still write each notification's body in the form builder, and it appears inside the template. Until you add your own, notifications arrive in Formable's simple default layout - except a body you wrote as a complete HTML document, which is sent exactly as before. The plain-text version is generated from the email automatically, for text-only email clients and because spam filters mark down mail without one. See Email template in the notifications documentation.A notification or integration delivery that failed can now be sent again from the form builder. When an email can't be sent or a CRM is down, Formable retries on its own for a few minutes and then stops; until now that left a Failed row in the log and no way to recover the submission short of re-entering it by hand. The Recent sends list on the Notifications tab and Recent deliveries on the Integrations tab now show a Resend button on a failed row, and a new Submission column with the time of each attempt, linked to the submission it was for. Resend tries once, straight away, tells you whether it worked, and adds the attempt to the log. It uses the notification or integration as it is set up now, so fix what went wrong first - a mistyped address, an expired API key - and then resend. The button only appears on the most recent attempt for a submission, and that guarantee holds both ways round: a delivery an automatic retry already recovered can't be resent, and a resend can't be double-delivered by a retry still waiting in the queue behind it. It isn't offered for a submission you have since deleted or marked as spam, or for an integration the form no longer sends to. Resending to an integration needs Pro, like integrations themselves. See Resending a failed delivery in the notifications and integrations documentation.
A form can now be rendered Compact. The setting sits under General in the form's settings, with two choices - Comfortable (the default, unchanged) and Compact, which tightens the height and padding of every input and the space between fields. It is one setting for the whole form, so a form you leave on Comfortable renders exactly as before. Compact reads from the bundled stylesheet; a form with the stylesheet turned off (
themeLevel: 'contract') spaces itself and ignores the setting.Fields in a row can now be given a width. Each field's settings drawer has a Width control - Auto, Full width, Half, Third or Quarter - under Appearance, next to CSS classes. Auto is the default and every field left on it splits the row equally, so a form you never touch renders byte-for-byte as it did before. Set one field to a smaller width and the others share what's left: a "First name / Last name / Suffix" row can hand Suffix a quarter and let the two names split the rest, instead of all three coming out the same width. The form builder's canvas shows the row taking shape as you go. Widths need the bundled stylesheet at level
resetor higher - a form running with the stylesheet turned off (themeLevel: 'contract') lays every field out itself and ignores the setting.Submission statuses can be edited at last, under Formable → Statuses. Add your own, rename them, give them any of Craft's status colours, drag them into the order you want to see them in, and delete the ones you never use. Every install had the same four - New, In Review, Approved, Rejected - in English, forever, because they were written once by the installer and nothing could touch them afterwards; the form builder's Default submission status picker has always offered exactly those four and nothing else. Statuses live in your project config, so the ones you set up on a development site deploy to staging and production with the rest of your configuration - which is why the screen stays unavailable wherever admin changes are locked down. Two statuses can't be deleted: the default and the last one left. Deleting any other moves the submissions currently sitting in it to the default, so nothing is left showing a blank where its status used to be. Existing installs keep the four statuses they have, and every submission stays in the status it was given.
Formable → Integrations and Formable → Statuses no longer require a full admin account. Two new permissions - Manage integrations and Manage submission statuses - can be granted to any user or user group under Users → Permissions → Formable, so an agency's client contact or a marketing manager can be given exactly this access without also being made an admin. Statuses still needs admin changes unlocked in the environment, same as before, since it's deployed configuration; Integrations does not, since it holds no deployed state, only credentials. The global Settings → Plugins → Formable screen is unaffected and stays admin-only - that one is Craft's own plugin-settings screen, not Formable's. See
docs/permissions.mdfor the full list of what Formable can grant.A new Rating field type collects a single score from 1 up to a configurable Scale (2-10, default 5) - previously the only way to ask for one was a Radio Buttons field with hand-typed option labels, as the bundled Survey preset (now updated) did.
Submissions now record which site they came from. On a multi-site install that shows up as a Submitted from column on the submissions index, a row on the submission detail page, a column in the CSV/JSON export, and a per-site breakdown in the weekly digest (Pro); templates and GraphQL can filter on it with
submittedSite('handle')and asubmittedSiteargument. Forms themselves stay global - one form, available on every site - so nothing about building or rendering a form changes, and a single-site install sees no new columns at all. Submissions stored before this release report no site: there was never anything recorded for them, and Formable would rather say so than guess.Formable now records which entries, categories and assets a submission's answers point at, so you can ask which submissions reference a given element:
craft.formable.submissions.relatedToElement(entry)in a template (Pro, scoped to the signed-in member's own submissions), orSubmission::find()->relatedToElement($entry)from a module. Craft's own Relations panel cannot show this - a form's fields are not Craft fields, so nothing about a form ever reaches Craft's relations table - which is why the question needed an answer of its own. Everything already stored is indexed when the plugin updates.The theming system gains a third tier of tokens, between the existing primitives and semantics: a
--formable-input-bg,--formable-progress-step-bgand around forty others like them, one per distinct control or surface, each defaulting to the same semantic token it always read. Before this, retinting "just the inputs" wasn't possible without also moving the progress indicator's step circle and the table's scroll-shadow fade, which happened to read the same underlying token for no reason beyond coincidence. Nothing about a rendered form's appearance changes - every new token is an alias of an existing value - but a site can now override one component (the inputs, a message box, the file-upload drag state) without any other surface following along uninvited. Seedocs/theming.md's Colour - components section for the full list.A form's Settings tab has a new Appearance section with four controls: Appearance (Plain or Bordered, the boxed/card treatment), Label Position (above the field, or to its left), CSS Class (your own class, added alongside the plugin's), and Theme Level, which overrides the plugin-wide stylesheet setting for this one form. Two forms on the same site can now look different from each other without a template override directory - a marketing form can run Bordered while the rest of the site stays Plain, or one form can render
reset-only while the global setting staysdefault. Every control defaults to matching today's rendered output, so an existing form is unaffected until you change one.The success screen has more to say than one line. A new Success Details setting, next to Success Message, adds a smaller line underneath it for what happens next or how long a reply takes - optional, and blank by default, so an existing form's success screen is unchanged until you fill it in. Both fields accept Twig, including
{{ submission.id }}, if you want to show a reference number - the Success Message field's own instructions now say so. Submitting over AJAX no longer strands a submitter on a dead end: the success screen now carries a "Submit another response" link back to a blank form, matching what a normal page submit already did.A rendered form now supports right-to-left languages. Set
dir="rtl"on the<form>or an ancestor - an Arabic or Hebrew site - and the whole layout mirrors: margins, padding, the select chevron, the stepper connectors, the error list's indent and bullets, and table cell alignment all follow the reading direction, with no template changes needed. The save-and-resume standalone page now sets its owndirfrom the site's locale, alongside thelangit already carried. Free-text fields (Single-line Text, Email, Website, Multi-line Text, and the Name/Address sub-fields) carrydir="auto", so one answer's script doesn't fight the form's overall direction.A rendered form's colour scheme is now an explicit setting - Appearance → Colour Scheme globally and per form (Light / Dark / Auto), and a
colorSchemerender option - rather than always following the visitor's OS setting. Light is the new default. Previously a light-only site could repaint a form dark, and near-white on near-white, for any visitor whose OS was set to dark mode with nothing on the site itself asking for that; a form now stays on whichever palette it's set to unless you choose Auto, which restores the old OS-following behaviour and additionally paints the form's own background so it stays legible on any page it lands on.renderFormandrenderStepaccept a newheadingLeveloption (default2): the page label, the review heading and the success heading all start from it, with the review's per-page sub-headings one level below. Set it to match wherever a form sits in your own page's heading outline -{{ craft.formable.renderForm('contact', { headingLevel: 4 }) }}for a form embedded under an<h3>, for instance.The bundled stylesheet gained a
--formable-unittoken (default0.25rem) that the whole spacing, type, control-height and radius scale now derives from - a site that shrinks its root font size (thehtml { font-size: 62.5% }trick) can bring every one of Formable's own sizes back with a single override instead of retuning each token by hand. A new--formable-form-max-inline-size(defaultnone, so nothing changes until you set it) caps the whole form's own width on very wide containers, distinct from the existing--formable-measure, which only bounds prose.docs/theming.mdgained an Embedding section - narrow containers, modal and sidebar forms, AJAX-loaded forms, Content Security Policy, how to place Formable's own@layer formablerelative to a host design system's layers (Tailwind v4, Open Props, or your own) when the default ordering isn't the one you want, and the two consequences of the step being a layout container: the width floor that keeps a form from collapsing inside a host that sizes itself to its content, and its effect onposition: fixedwidgets rendered inside a form.
Changed
Dropdown, Radio Buttons, Checkboxes and Multi-select answers now show the option’s label, not its stored value. This applies everywhere an answer is displayed: the submitter’s review step, submission titles and search, CSV exports, notification emails, Slack, Mailchimp, HubSpot, the webhook’s
dataobject, GraphQL’svaluefield and the edit history. If an option has been removed since a submission came in, its stored value is shown instead. Conditional logic still compares stored values, so rewording an option’s label never changes which rules match.Conditions on notifications and integrations read as what they do. They used to offer Show and Hide, like a field’s conditions. A notification now offers Send and Don’t send, and an integration offers Forward and Don’t forward. The rules and the stored settings are unchanged.
Name and Address sub-field labels are sentence case, like every other label: “First name”, “Middle name”, “Last name”, “Address line 2”, “State / region” and “ZIP / postal code”. A form using the default labels picks up the new wording, and a label an author has changed is left alone. If your site translates Formable’s strings, update those six keys.
The Integrations list names each integration’s type properly (“Mailchimp” instead of
mailchimp), and no longer prints the handle beside the name.Formable is now published as
bytesof/craft-formable, and its PHP namespace isbytesof\formable. Thevividvendor name the pre-release builds used belongs to an unrelated publisher on Packagist, so the plugin could not be released under it. If you installed a pre-release build, switch the requirement over withcomposer require bytesof/craft-formable(removing the oldvivid/craft-formableline) and then runphp craft up. An update migration rewrites the old class names everywhere they were stored - your forms, submissions, field layouts, dashboard widgets and any queued notification or integration jobs all carry over untouched, and nothing needs rebuilding by hand. A form exported from a pre-release build still imports correctly: its fields are recognized under either namespace. Anyone extending Formable in their own code - a custom field, integration or preset, or an event handler - needs to update theirusestatements tobytesof\formable\…; the class and method names themselves are unchanged.Submission lists stay fast on large sites. A form's submissions in the control panel, the “Recent Form Submissions” widget, the weekly digest and the submission-limit check now sort and filter through indexes built for exactly those queries. With a million stored submissions, opening a busy form's submissions went from about two seconds to a few milliseconds, and checking a form's submission limit no longer counts its whole history on every page view. The update adds two database indexes to the submissions table, which takes under a minute even on a very large table.
Expired and retention-window submissions are now deleted in the background. Craft's garbage collection used to delete them itself, all at once, inside whichever visitor's request happened to trigger it - which, the first time retention was switched on for a form with a long history, could mean loading hundreds of thousands of submissions into one page load. It now queues a job that deletes a few hundred at a time and queues another for the rest, so the backlog clears without anyone's request paying for it.
The documentation on caching forms now covers every form, not only multi-page ones. It used to say single-page forms were unaffected by page caching. They aren’t: a form on a page cached by Craft’s
{% cache %}tag, Blitz or a CDN carries one baked-in CSRF token, so every visitor but the first is turned away with a 400 on submit. Caching forms in the templating documentation now says so, and describes keeping the form outside the cached region, Craft’sasyncCsrfInputssetting and Blitz’s own CSRF injection as ways to keep the page cacheable.Every layout switch (row columns, the stepper's orientation, the review page's split, labels-left) now responds to the space a form's own step actually has, not the browser viewport. A 280px sidebar gets the narrow layout even on a wide desktop screen, and a form inside a
<dialog>or a card gets whichever layout its own container earns - previously all of these switched on viewport width alone, so a form embedded in anything narrower than the whole page could render three-column rows or a horizontal stepper it had no room for. One side effect is worth knowing if you render a third-party widget inside a form: making the step a container also makes it the reference forposition: fixeddescendants, so a portalled date picker or an "expand to full screen" control that expects to escape to the viewport will now position itself against the step instead. Give a widget like that a portal target outside the form. Nothing Formable itself renders is affected.Overriding
--formable-*tokens now works the way the documentation always described. A:rootrebrand, or one scoped to a wrapper of your own around an embedded form, now reaches every surface that paints with it, including inside<form>itself - previously every token was re-declared on.formable-format its own default, so only overriding.formable-formdirectly (not:root, not a wrapper) actually took effect.data-formable-scheme, for forcing a colour scheme from your own markup, now works on any ancestor rather than only:rootor the form element itself.A rendered form now survives a hostile host stylesheet - Tailwind Preflight, Bootstrap Reboot, Tailwind Typography's
.prose- instead of losing its borders, its primary button's fill, or having its list resets stripped. A small set of structural rules (list markers,<legend>/<fieldset>sizing, control borders, the primary button's fill) now sit outside Formable's own cascade layers at a documented specificity so they hold up against an unlayered site-wide reset, and the accessibility-critical rules (visually-hidden labels, hidden-field handling) can no longer be defeated by one at all. The global[hidden]rule a form's markup relied on is now scoped to Formable's own elements, so it no longer interferes with an unrelatedhidden="until-found"element elsewhere on the page.Several controls now adapt to the container they're actually rendered in, not just the page: the Signature field's canvas resizes instead of overflowing or distorting the signature; every control's full-width sizing now applies even at the lighter
reset/contractstylesheet levels; long unbreakable text (a URL in a review answer, for instance) wraps instead of blowing out the layout; the Multi-select field's option popup flips upward when there's no room below and scrolls itself into view; a captcha renders at its compact size in a narrow container and follows the resolved colour scheme instead of always staying light; the stepped progress indicator collapses to a single "Step 2 of 6 · Contact info" label below a width threshold or with eight or more steps; a Table field's columns stack as cards instead of scrolling sideways in a narrow container; and the form's action buttons stack full width instead of crowding a row.Touch and pointer handling is more deliberate: hover-only styling is now gated behind
(hover: hover)so a tap doesn't leave a "stuck" hover state on a touchscreen, buttons and the multi-select's chip-remove control grow to a proper touch target size under a coarse pointer, and every text input's font size floors at 16px there so iOS no longer zooms in on focus. Forced-colours mode (Windows High Contrast) now also renders the select's chevron, the stepper's connectors, the current step's circle and the file-upload dropzone's border correctly, alongside the input/progress/done-tick coverage that already existed.Rendered forms now arrive styled. Formable ships a complete visual theme - colour, typography, spacing, hover and focus states, disabled-field styling, dark mode - and it is on by default, where before the plugin rendered bare, unstyled markup unless you opted in. The theme is built to be overridden: every rule sits in its own CSS cascade layer, so any style you have already written for forms on your site wins over it without needing
!important, and it paints entirely through--formable-*custom properties you can redefine to rebrand it in a few lines. Two lighter levels are available -resetgives you layout and the accessibility scaffolding with no visual opinion, andcontractdrops the layout too and ships only the accessibility contract, for a headless or heavily-Tailwind site that wants none of Formable's layout opinions but still needs the markup's hidden-label and hidden-field rules honoured. Choose one under Settings → Formable → Form Stylesheet, or per render with{{ craft.formable.renderForm('contact', { theme: 'contract' }) }}. Upgrading and want your forms to look exactly as they did? Add'themeLevel' => 'contract'toconfig/formable.php, or set Form Stylesheet to Contract only in the settings, before you deploy. Any site that had already turned the stylesheet on - through the old setting or'includeTheme' => truein config - keeps the theme and needs no change.Formable's stylesheet keeps its accessibility rules in a cascade layer that always wins. The visually-hidden label utility,
[hidden]handling and the keyboard focus-ring shape live in aformable.contractlayer that is now declared last, ahead of the layout and paint layers rather than behind them, so a screen-reader-only label can't be un-hidden by one of Formable's own later rules. The declared order isformable.tokens, formable.reset, formable.theme, formable.contract. If you write your own layered CSS against a Formable form, keep anything that must not weaken its accessibility unlayered, or in a layer you order afterformable.contract.A selection whose target has since been deleted no longer renders as nothing. An entry someone picked and an administrator later moved to the recycle bin still shows its title in the control panel, in exports and in notification emails; one that has been permanently deleted shows as
Deleted element 42. Before, both came out blank - indistinguishable from a question nobody answered. The stored answer itself is never rewritten: a submission is a record of what somebody submitted, not a pointer that gets tidied up when the world moves on.The plugin settings screen no longer offers Data Retention (days) or Collect IP Addresses. Neither setting was ever read: retention and IP capture are decided on each form's own Settings tab, and always have been. A site that had set the plugin-wide retention field to 30 days was being told its submissions were purged on that schedule when nothing was purging them at all - so the controls are gone rather than left standing as a promise Formable does not keep. Nothing changes about how forms behave; the per-form settings are unaffected, and the two unused values are removed from project config when the plugin updates.
Sender Email and Sender Name are now Digest Sender Email and Digest Sender Name, and have moved into the weekly digest section of the settings screen (Pro). They only ever addressed the digest - notification emails are sent under Craft's own email settings, as they were before - and the old labels implied otherwise. Any address and name already set are kept as they were.
The submissions index search box now searches the answers, not just the submission title. The title is only ever a summary of the first couple of fields, so searching for an address, a company or a reference number found nothing unless it happened to land in one of them - which is the single most common thing anyone does on a submissions index. Every field's value is indexed now, so
acme.testfinds the submission whichever field it was typed into. Fields marked Encrypt value are deliberately left out: the search index is plaintext, and putting a decrypted value in it would undo the point of the setting. Submissions stored before this release were only indexed by title; runphp craft formable/submissions/reindexonce to bring them up to date, optionally narrowed to one form with--form=<handle>.The form builder is now translatable throughout. Roughly 140 strings - every label, button, placeholder, hint, screen-reader description and error the builder can show - go through Craft's own translation layer, against about 18 before, and a translator picks them up from
translations/en/formable.phpalongside the rest of the plugin. Nothing changes for an English install; on a translated one, the builder no longer falls back to English part-way through a screen.Forms with a lot of fields are considerably cheaper to handle. A form's fields used to be rebuilt from its stored layout every time anything asked for them - and the things that ask are loops, so reading a submission's values, assembling a notification, running the spam checks or exporting to CSV each rebuilt the whole form once per field. A 25-field form now builds 25 field objects per request instead of several hundred, which is most noticeable where it was worst: exporting or purging thousands of submissions, the weekly digest, and busy multi-page forms.
Walking through a multi-page form no longer stores anything. The answers given so far are held in the submitter's own session until they press Submit, instead of being written to the database - and kept for 30 days - on every step. Only save & resume stores a part-filled form now, which is what the submitter asked for when they requested a link to finish later, and what the privacy documentation always said happened. A form abandoned on page two leaves no record behind, and a public multi-page form can no longer be made to fill the submissions table by anyone posting to it in a loop. Next, Back and Save are also weighed against the spam checks that hold true mid-form - the honeypot, the JavaScript token and the IP blocklist - so nothing is stored or emailed on behalf of a post that has already given itself away. The timing and keyword checks still run only at the submit, where they belong: neither means anything about a page of a form that isn't finished.
The File Upload field is easier to use on a rendered form. It now renders as a real dropzone - a bordered "drag files here, or click to browse" area, not the bare native picker - and drag onto it or click it to open the file dialog either way. Once files are chosen it lists them with a thumbnail (an image preview, or a generic file icon for anything else), a live upload progress bar per file while the form is submitting, their sizes, and a remove control for each, so dropping one file from a selection of several no longer means picking them all again. If the field has a maximum file size, an oversize file is caught and named in the browser before the upload starts, rather than after it finishes and the server turns it away; the same happens when a drop would push past the upload limit. The plain input still works with JavaScript off, and the server continues to check the size, the count and the allowed file types of everything that reaches it. Per-file progress is an estimate - forms submit as one request, not one upload per file - and always reaches 100% exactly when that file's share of the request finishes sending.
A rendered form now has three levels of vertical rhythm instead of one flat gap. Fields sharing a row, rows stacked in a page, and the form's major regions (the error summary, the step content, a review page) each get their own spacing, tighter to looser in that order, so the layout itself shows which fields belong together instead of every gap reading the same. A site that overrode
--formable-gapkeeps affecting the middle of those three levels (row-to-row spacing) and picks up the new tighter/looser values at the other two.The spacing scale (
--formable-space-1through-8) now steps by a consistent amount instead of a different ratio at every rung, and gets two rungs it was missing,1.5remand2rem.-1through-6are unchanged. If you override--formable-space-7or--formable-space-8directly, they now hold1.5remand1.75reminstead of1.75remand2.5rem- those two values moved to the new-8and-10rungs, which is also where the form's row-to-row and page-to-page rhythm now read from, so nothing about a form's own rendered spacing changes.Submitting a form now shows a spinner on the button itself, in place of dimming the whole step to 60% opacity. The old dimming hid the content someone might have wanted to re-read while waiting, and dropped its contrast below AA for as long as it was showing. The step still freezes to interaction while a request is in flight - only the paint changed.
The
*marking a required field is no longer painted in the error colour. A form with several required fields used to show several red marks before anyone had done anything wrong; the asterisk now reads in the same neutral tone as a field's instructions or hint text, so red is left to mean an actual validation error.A single-page form's Submit button is now large. The button size scale already had a large size; nothing used it, so the most common form the plugin renders - one page, one action - gave its only call to action the same default-size treatment as a secondary Back or Save button standing next to it. A multi-page form's Next and Submit are unchanged.
A field that fails validation now shows its error two ways instead of four. It used to get a red accent bar in the gutter, a red label, a red input border and a red message all at once; now it's the border and the message, which is what a screen reader announces and where the actual "why" lives. The form-level error summary at the top of the form is unchanged.
The Section Divider field now groups the fields around it instead of drawing a line between them. Place one on its own between two runs of fields and each run gets a bordered card, which is what lets a long form finally read as chunks rather than one flat list; a form with no Section field is unaffected. If you were already using Section fields as plain dividers, the line is gone and your fields are boxed instead.
The Multi-select field is now a searchable box with removable chips for what's picked, in place of the native scrolling list box. Type to filter a long option list instead of scrolling it, see everything already selected as a chip you can remove with a click or, at the end of the row, with Backspace, and pick with a mouse without the ctrl/cmd-click most visitors never learned. The underlying
<select>is unchanged - it's still what posts, so nothing about a form's stored answers or its conditional logic moves - and a browser with JavaScript off, or a page the combobox never reaches, still gets the plain list box. The Visible Rows setting now only affects that fallback.The grey palette behind a rendered form is now one consistent scale instead of two greys stitched together, and every rung from lightest to darkest is now actually lighter or darker than its neighbours. It used to skip two rungs outright, and its darkest-but-two step rendered lighter than the two steps above it - a site overriding that one expecting a muted mid-grey got a muted mid-grey only by accident of a mislabelled scale, and one overriding a neighbouring rung to get a darker tone could get a lighter one instead. Every other grey a form paints with - borders, backgrounds, the muted colour on hint text - reads the same as before; if you override
--formable-color-neutral-900directly, you now get a near-black tone rather than the muted grey it used to carry, because that grey moved to the rung it always should have been on (--formable-color-neutral-600).The primary button, the secondary/ghost button text and the checked-checkbox colour are no longer Tailwind's default blue - they're near-black on a light page and near-white on a dark one, matching whichever surface they sit on instead of importing a brand colour into every rendered form. Blue is still there, just narrowed to the two places it earns its keep: the focus ring, and the progress bar/stepped indicator, both unchanged. If you were overriding
--formable-color-accent-500/-600/-700to retint the button, move that override to--formable-button-primary-bg(and-hover/-text) instead - the accent ramp no longer reaches the button at all, and-700no longer exists, having lost its only reader. If you were overriding--formable-button-bgexpecting it to move the progress bar along with the button, it no longer does; retint the progress bar with--formable-progress-fillor--formable-color-accent-600directly.A rendered form's step title now stands clearly above an in-page Heading field, where the two were nearly the same size before. The step title moves up to
1.5rem(a new--formable-font-size-2xltoken), and both it and the heading now paint at the theme's own semibold weight rather than the browser's heavier default. Field labels and instruction text get a1.5remline-height of their own (--formable-line-height-normal, reinstated) so a label or hint that wraps to two lines keeps its spacing regardless of the host page's body leading. If you override--formable-font-size-xlto size the step title, move that override to--formable-font-size-2xl;-xlis now only the resume page's body-copy rung.
Fixed
Checkboxes and radio buttons line up with their labels. They sat a couple of pixels above the text on any page with a line height above the browser default, which is most sites. They’re now centred on the label’s first line whatever the page’s line height or font size.
Submission lists open newest first. Every submission list, including All submissions and Spam, was meant to sort by date created, newest first, but opened in the order submissions were saved until you clicked the column header.
A condition’s value is readable in the notification and integration panels. A long field name used to squeeze the value down to a few characters and push the remove button onto its own line.
A form whose handle was reused while it sat in the trash can be restored again. Formable lets a new form take the handle of a trashed one, but restoring the trashed form then failed with nothing more than “Forms not restored.” It now comes back under the next free variant of its handle -
contact2,contact3and so on - and the live form keeps the original, which is the one your templates already use. Rename either in the builder if you want them the other way round.Saving a form is now all or nothing. The form and its notifications used to be saved one after the other, so a notification that failed to save left the form's new layout in place anyway - with its notifications still pointing at the old field handles. Both now save together or not at all, and
formable/forms/importreports a notification that could not be saved instead of quietly skipping it.On Lite, a form with conditional logic can no longer hide a field it still requires. Conditional logic is a Pro feature, and on Lite the server ignores it, so every field shows and keeps its required setting. The browser was still applying the rules, though, and could hide a required field, leaving the visitor with an error they had no way to fix. On Lite, forms no longer send their conditions to the browser, so every field shows, as the server expects. The GraphQL
conditionsfield on a form field returns null on Lite for the same reason. Nothing changes on Pro.A CSV export can no longer run a visitor's answer as a spreadsheet formula. Submissions come from anonymous visitors and are opened by admins in Excel or Sheets, where a cell beginning with
=,+,-or@(or a tab or carriage return) is executed as a formula. The CSV export now prefixes any such cell with an apostrophe, so the spreadsheet shows it as the text it is. Plain numbers such as-5are left alone, and JSON and XML exports are unchanged.A non-admin who can build forms can no longer use one to reach the rest of your Craft install. A success, error or closed message, a redirect URL, an upload subfolder, and a notification's subject, body and addresses all accept Twig, written by whoever holds the Create and edit forms permission - which does not require an admin account. That Twig now always runs sandboxed, regardless of your site's own Twig sandbox setting:
{{ craft.app.config.general.securityKey }}(or anything else reachingcraft,currentUser, or another global) is refused rather than rendered. A form's own submitted values are still reachable -{{ name }}or{name}in a message still resolves a submission'snamefield. The HTML block field's content, the Agree field's description and a form's Closed Message, all previously printed as-typed on the same admin-only assumption, are now purified instead, so a stored<script>no longer runs in a visitor's browser or the CP preview. Craft CMS 5.10.7 is now the minimum supported version, the first release with the sandbox mechanism this needs.A File Upload field can no longer be filled by naming an existing file. A visitor could post the ID of any asset already in your Craft install - including one in a private volume - as a File Upload field's value, and it was stored as their upload; a notification set to attach files would then email it to them. The value of a File Upload field now comes only from a file the visitor actually uploaded, on a posted form, on a later page of a multi-page form, and through the GraphQL mutation alike. Anything posted for one of these fields other than the file itself is ignored, so a required upload still reports as missing, and files already uploaded on an earlier page are kept.
Member self-service submission editing and deletion are now off by default, per form (Pro). Until now,
formable/submissions/update-ownanddelete-ownworked for every form the moment a member was logged in, reachable by a direct POST whether or not you'd actually built a self-service page for it. A new Member Self-Service setting, under a form's Settings → Privacy & data, turns this on per form; left off - the default, including for every existing form - both actions now respond exactly as if the submission didn't exist. A form that has opted in still refuses once it stops accepting submissions - disabled, or outside its schedule window - even for the submission's own owner, and an edit throughupdate-ownnow re-applies the form's conditional logic, so an answer behind a field the new values just hid no longer survives the edit. See Member self-service submissions in the templating documentation.Fixing a typo in a saved field's label can no longer silently rename its handle. The handle control used to regenerate from the label on every edit unless you had already touched it yourself in the current session - so relabelling a field you built last week, with nothing else changed, quietly moved it to a new handle. A condition, a notification's conditional send, or an integration mapping that pointed at the old one kept compiling and simply stopped matching, so the field it was meant to show, notify on, or send stayed permanently hidden with no error anywhere; submissions already stored under the old handle stopped surfacing in the CP, exports and tokens for it. The handle control now only auto-follows the label for a field that isn't saved yet - editing a saved field's label leaves its handle alone unless you edit the handle itself. Renaming a saved field's handle directly now rewrites every condition and integration mapping that pointed at the old one to point at the new one instead, and, when the form has stored submissions, asks you to confirm first because their answers are moved to the new handle in the background once you save (see the entry on stored answers below). Saving a form with a condition or a conditional-send rule that names a handle nothing in the layout has is now rejected server-side, rather than silently compiling into a rule that can never match. The same goes for a switched-on integration (Pro) whose conditions or field mapping name a missing field - usually one you deleted - on a builder save and on
formable/forms/import, where it used to go on saving and quietly stop sending that field, or stop sending at all.Renaming a field's handle now carries its stored answers with it. A handle is the key a submission's answer is filed under, so changing one used to leave every existing submission's answer under a name nothing read any more - it vanished from the control panel, exports, notification tokens and integrations, with no error. Formable now recognises a field by its internal ID rather than its handle, so when you save a form it notices which handles changed and queues a job that moves every stored answer for that form - completed, incomplete, spam and trashed submissions alike - to the new handle, along with the index that lets you find submissions by the entries and categories they picked. It works through the form's submissions in batches, so a large form saves at once and its older submissions catch up as the queue is worked, and two fields swapping handles in one save are exchanged rather than overwriting each other. What Formable can't rewrite for you is text you wrote: a
{token}naming the old handle in a notification's subject, body or addresses, and any of your own templates that read it. After a save, the builder lists the notifications it found still using an old handle so you can fix the wording; the docs cover the rest. Changing the option values of a Dropdown, Radio or Checkbox field is not migrated, since a value is the answer itself, so answers stored under the old value stay as they were.Deleting a form now moves its submissions to the trash with it, and restoring the form brings them back. Until now, only the form itself was ever deleted - its submissions stayed live indefinitely, kept showing up anywhere a template or GraphQL query looked for them, and weren't covered by a Pro data-retention window even though the form they belonged to was gone. Worse, once a trashed form was eventually purged for good, its submissions were left behind with no form and no way to find or delete them, still holding whatever was submitted. A form's submissions are now trashed alongside it and restored alongside it too - unless one was already trashed on its own beforehand, in which case restoring the form leaves it exactly as you left it. A form with many submissions defers this to a background job so deleting or restoring it doesn't hold up the control panel.
Permanently deleting a submission now deletes the files uploaded with it. Until now an uploaded CV or ID scan stayed in its volume after the submission was gone, whether you deleted it by hand, a Pro retention window purged it, or Craft emptied it from the trash, so an erasure request never actually erased the files. They now go with the submission. Moving a submission to the trash leaves its files alone until it's permanently deleted, and restoring it keeps them. Only files Formable itself uploaded for that submission are ever deleted, never an asset that was already in the volume. To keep uploads after their submission is gone, turn off the new Delete Uploads With Submissions setting under Formable → Settings → Data protection. Files uploaded before this release aren't tracked, so they're never deleted automatically.
The notification and integration logs no longer keep personal data indefinitely. Log entries older than 90 days are now cleared during Craft's garbage collection. You can change the window, or set it to 0 to keep the logs indefinitely, with the new Delivery Log Retention (days) setting under Formable → Settings → Data protection. An integration log entry also keeps less. A successful delivery no longer stores a copy of what was sent. A failed one still keeps the request and response so you can diagnose it, but with any answer to an Encrypt value field masked. The Right to erasure section of the GDPR documentation now spells out exactly what permanently deleting a submission removes and what it can't reach.
A form that doesn't store submissions can no longer have an integration switched on. With Store Submissions off, a form still sends its notification emails, but an integration has no saved submission to deliver - so switching one on used to save without complaint and then forward nothing, with no error anywhere. The Integrations tab now says so, won't let you switch an integration on while storage is off, and the Store Submissions setting warns you which ones to switch off first. Saving is refused if you try anyway. A form already saved this way will show the same error the next time you save it: turn storage back on, or switch those integrations off. An integration you have since deleted or disabled under Formable → Integrations doesn't count. See Forms that don't store submissions in the notifications and integrations documentation.
A submission made through the GraphQL API now respects the same submit rate limit as one posted from the web. The
saveFormableSubmissionmutation reached the submission pipeline directly, bypassing the throttle that already protected a posted form - an unbounded number of mutations against the same form could complete with no limit anywhere. Both paths now spend from one shared budget, so a cap set under Spam protection holds regardless of which one a submission arrives through.A file attached to a submission is no longer uploaded when another field on the same page fails validation. Until now the file was moved into its volume and turned into an Asset before the rest of the page was checked, so a submission rejected for an unrelated reason - a blank required field alongside the upload - still left a real, orphaned file behind. The upload now waits until the rest of the page is confirmed valid; resubmitting with the fix in place uploads the file normally.
A notification's body now HTML-encodes a submitted value before it reaches an HTML email.
{{ name }},{name}and{{ values.name }}used to interpolate whatever a submitter typed with no escaping at all - a Text or Email field holding<script>…</script>would run in the recipient's mail client, and an autoresponder that echoed a value back could be turned into a phishing relay against the submitter themself. A value now always prints as plain text; a notification's own written-in markup (<p>Thanks, {{ name }}!</p>) is untouched, so existing bodies render exactly as before unless they specifically relied on a submitted value carrying real HTML - a rare case, and one that has never been advisable. The all-fields fallback table this replaces when a body is left blank was already safe. From name and From email can no longer have a line break injected into them by a submitted value, and a form's redirect URL can no longer be turned into ajavascript:link by one. See Templating a notification in the notifications documentation.An Entries or Categories field now actually offers something to pick. Its rendered
<select>used to have no options at all, regardless of how many entries or categories existed - the variable the template printed from was never populated by anything. It now lists the live entries or categories from whichever sources the field's Sources setting scopes it to (every native source, if left blank), capped at 250 combined, and a submission naming an id outside that set - disabled, unpublished, or from a source the field wasn't scoped to - is now rejected rather than silently accepted. The Users and Assets field types from this release's Added section are withheld from the builder's palette for now: an anonymous form enumerating an install's user accounts or files is a separate decision.Duplicating a field, or adding one from the palette by click or drag, no longer throws in the browser. All three read the field's starting values off the builder's reactive state and handed that straight to
structuredClone(), which unconditionally rejects a Proxy - not a rare edge case, every call - so nothing was ever actually added to the canvas; the error only showed up in the browser console. Found while adding undo/redo below, which needed the same fix for its own snapshots.An embedded form now fits the container it was put in, and fills it. A form in a container narrower than 20rem - a 280px sidebar, a phone once the page's own padding is taken off - used to render wider than its box and leave the whole area scrolling sideways, because the floor that keeps a form from collapsing inside a shrink-wrapped
<dialog>measured itself against the browser window rather than the container. It now yields to the container, and a modal form still opens at a usable width; the Signature field's canvas, which drew its border outside its full width and bled a couple of pixels past the same edge, was fixed with it. Separately, a form placed directly in a flex-column or CSS-grid wrapper - ordinary Twig template markup - used to shrink to the width of its widest label instead of filling the column; it now fills it, and still centres itself when--formable-form-max-inline-sizecaps it.Secondary buttons - the Signature field's Clear, the Table field's Add row - no longer render with the primary button's near-black fill behind their own near-black label, which left them all but unreadable. They are transparent again, as they were meant to be; only the primary button carries the fill.
The bundled template pack no longer writes an inline
styleattribute anywhere, so it now works correctly under a strict Content Security Policy (style-srcwith no'unsafe-inline'). Under such a policy the honeypot field used to become visible - and any visitor who filled it in was then rejected as a bot - the multi-page progress bar's fill was stuck showing 100% on every step, and a textarea's configured row height and a Table field's author-set column widths were silently dropped. The same three now set their few CSS-only values through the script bundle instead, which astyle-srcpolicy doesn't govern; without JavaScript at all each degrades to something merely imprecise rather than wrong.A form's
formable:success,formable:error,formable:savedandformable:closedevents - the ones a site listens for to close a modal or fire an analytics call - now actually reach adocument-level listener. Previously the form element they were dispatched on had already been replaced in the document by the time the event fired, so nothing upstream ever received it.Checkbox and radio focus, and the Multi-select and File Upload controls' focus rings, no longer disappear entirely in a browser old enough to lack
:has()support (Safari before 15.4, Firefox before 121) - they now fall back to a:focus-withinring in the same place.A checkbox or radio group's rows no longer overflow and get clipped when the form sits inside a container with no side padding of its own.
A form that fails validation now moves the visitor to the error summary at the top, rather than leaving them where they were with nothing said. A screen reader announces the summary and its list of links to each failing field on arrival; a keyboard user's next Tab starts from there. This now works the same way whether the form reloads the page or submits over AJAX: previously the page-reload path rendered the summary but never focused it, and the AJAX path tried to focus the first invalid field but landed on an element that could not take focus, so in both cases a failed submission was effectively silent for anyone not looking at that part of the screen.
The form builder's canvas can now be reordered without a mouse. Every field card gets Move up / Move down buttons alongside Duplicate and Delete, and every row gets the same pair beside its drag grip - both back onto the same layout the drag handles have always moved. Before this, ordering fields and rows was a Sortable pointer drag with no alternative, so a keyboard-only author could add fields and edit their settings but had no way to put them in the right order.
Tabbing to a checkbox or radio option now rings the whole option row, not just the small native box inside it. The clickable/hoverable area around each option has always been larger than the box itself - now the keyboard focus indicator matches it, the same way clicking or hovering already did.
A Dropdown field no longer sits at a different height than the text input beside it in the same row. The fixed height token only ever reached the select; every other control derived its height from padding plus whatever line-height the page happened to inherit, so a site with different body typography got a visibly mismatched row. Every control now shares the same height floor.
The Dropdown field's chevron now follows dark mode and a rebrand. It used to be baked into the arrow's image as a fixed grey, so it stayed the same dim grey against a near-black background instead of repainting the way the rest of the form does.
Saving a brand-new form right after splitting a field into its own row no longer throws in the browser, even though the save had already succeeded.
structuredClone()rejects a Proxy the same way it did for the duplicate/palette-add bug fixed above - this time because splitting a field read the moved field back offArray.prototype.splice()'s own return value, which is built outside the builder's reactive state and so was still a Proxy underneath. Saving a form for the first time right after a split threw while recording undo history for the save, after the server had already stored the form but before the screen moved off the "create" URL - so the status bar showed a raw browser error on a form that already existed, and saving again from there created a duplicate. A split now unwraps that field before it's used.The Table field's scroll-shadow hint - the fade at either edge signalling there's more to scroll to - now shows up in dark mode. It was painted as a fixed black tint, which all but disappeared against the near-black surface a dark form renders on; it now derives from the same text colour the rest of the form already inverts, so it's visible in both.
On a multi-page form using the progress bar, a screen reader now announces which step you are on. The bar reported a position but had no name attached, so it was read out as a bare progress indicator with no indication of what it was tracking. The stepped indicator was unaffected.
A multi-page form re-rendered without JavaScript now shows the answers already given. Stepping back to an earlier page used to present it empty, and stepping forward again posted those blanks over what had been filled in.
The Signature field can now be completed with a keyboard. Drawing needs a pointing device, so anyone using a keyboard, a screen reader, or switch or voice control was locked out of a form the moment a Signature field on it was marked required. Alongside the drawing pad there is now a text box: type your name and it is rendered onto the same canvas in a handwriting face and stored exactly as a drawn signature would be. Clearing resets both, and starting to draw discards a name that was typed.
A multi-page form carrying a signature or a large table no longer risks losing a page of answers between steps. Progress is held in the submitter's session, and Redis or Memcached - the usual session stores on a production site - drop anything over 1 MB without reporting it, so a near-full signature written on every page advance could silently vanish. Once a form's in-flight answers pass 256 KB they move to a stored draft instead - the same place a saved-and-resumed form lives - which has no such ceiling; every other form is unaffected and still stores nothing until Submit.
Two browser tabs open on the same multi-page form no longer interleave into one another. Each pass through a form now carries its own hidden identifier, so a second tab keeps its answers - and its page position - in a slot of its own instead of writing over the first tab's progress.
A time-only Date/Time field can now be given an earliest and a latest time, and the browser enforces them as you pick - the Earliest Time and Latest Time settings appear when a field collects a time but not a date. Before, the only bounds were Earliest Date and Latest Date, which a time-only field never showed, so a limit set in the field's validation was invisible to the person filling it in until they submitted and were turned away. A field that collects both a date and a time also now hands the browser a complete lower and upper bound rather than a date the browser quietly ignored.
Save & resume can no longer be hammered from one address. A script posting the save step in a loop used to store an unfinished submission and send a resume email every time; now a single IP gets a handful of saves per form in a ten-minute window and further attempts are quietly ignored, so it cannot be turned into a mailbox flood or a way to fill the submissions table. The limit is adjustable, or switchable off, under Formable → Settings → Spam protection. A genuine submitter parking a form to finish later is nowhere near it.
An ordinary submit, and a multi-page form's last Next click (which submits too), are now rate limited as well - against their own Submission Limit setting, separate from Save & resume's, and defaulting higher (30 per 10 minutes) since a busy shared connection submitting a form repeatedly is far more plausible than the same address parking one for later. Previously only Save & resume was bounded, so a script that ran JavaScript and left the honeypot alone could loop the submit step without limit - storing a submission, sending every notification and firing every integration each time. A file attached to a submit past its cap is also not uploaded - checked against the same numbers, and only once a request is confirmed to actually carry a file - closing the same loop for a volume that a script staying just under the row-and-notification cap could otherwise still run through. This is separate from the existing rule that a file attached to a request the spam checks would turn away (the honeypot, the JS check, the IP blocklist) is never uploaded in the first place. Every time either rate limit turns a request away, a warning is now written to
storage/logs(categoryformable) naming the form and the limit, so throttling stays visible to whoever reads the logs even though the submitter is never told. See Rate limiting in the getting-started documentation.The Email field's Require Confirmation setting no longer breaks the field it is on. The confirmation box used to post under a name that swallowed the address itself, so a required email field could never be submitted and an optional one quietly threw the address away - and the two entries were never actually compared. The confirmation now posts separately, the address arrives intact, and a mismatch is reported on the field. Conditional rules that read an email field with confirmation switched on also work again.
A text input, textarea, select or multiselect's border is now visible against the page behind it, in both the light and dark colour schemes. It's the only edge those controls have - the fill behind them is the same colour as the surface - and the resting border colour failed WCAG's 3:1 non-text contrast minimum in every pairing it was actually used in. The progress indicator's step circles and the submissions table's header rule read the same token and are corrected the same way. The border is a visibly darker hairline now; nothing about layout, borders elsewhere in the theme, or the hover/focus/error border colours changes.