For the complete documentation index, see llms.txt. This page is also available as Markdown.

AI Form Builder Guide

A reference for building CraftForms starters and demo pages using WP CLI and block HTML.

Abilities API (WordPress 6.9+)

CraftForms registers two abilities with the WordPress Abilities API. For AI agents, abilities are the preferred way to create forms and add fields — they handle post creation, meta setup, and block HTML generation correctly without requiring WP CLI access.

Registered abilities

Ability
What it does

craftforms/create-form

Creates a craftforms_form post, sets _craftforms_form_meta (formula, transformations, submit actions), and scaffolds minimal block content

craftforms/add-field

Generates correct block HTML for a field type and inserts it into the form's post_content before the submit button

What the abilities can and cannot do

Abilities generate guaranteed-valid block markup, so prefer them for the field types they cover. They are deliberately a small surface — know the ceiling before you plan a build around them:

Covered by abilities
Not covered — author blocks yourself

Field types

text, email, number, textarea, select, radio, checkboxes

datepicker, file upload, range slider, color picker, repeater, payment, WooCommerce, order summary, infoblock

Structure

append a field before the submit button

reorder, edit, or delete a field; layout groups; multi-step

Pricing

formula + transformations at create time only

any later change to formula, smart variables, or conditional logic

craftforms/add-field is append-only. There is no ability to modify or remove a field once added — an agent can build up, not refactor.

Ability vs WP CLI

  • Use abilities for the seven basic field types — they handle post creation, meta, ARIA wiring, and block validation correctly with no chance of a markup mistake.

  • Use WP CLI + hand-authored block HTML for everything else: any field type in the table above, any edit to an existing form, and any complete starter file. This is the majority of real builds — follow the block patterns in this guide and heed the two CRITICAL sections below.

  • A common, effective pattern is both: scaffold with create-form (which gets meta and the form wrapper right), then hand-author the remaining field blocks into post_content.

Calling abilities over MCP

Abilities are a registry, not an MCP server. To let an MCP client (Claude, Cursor, etc.) discover and call them, install the WordPress MCP Adapter and register a server that allowlists the CraftForms abilities. Note that MCP tool names are slugified — craftforms/create-form is exposed as craftforms-create-form. See WP Abilities API for the server-registration snippet.

Authoring conventions still apply

When craftforms/add-field generates block HTML, it follows the same patterns described in this guide (label + input + form-error structure, ARIA attributes on radio/checkboxes wrappers, name[] suffix for checkboxes, etc.). If you are generating block HTML yourself outside of the ability (e.g. for a full starter file), continue to follow the patterns in this guide.

Further reference

See WP Abilities API for the full input/output schema for each ability.


CRITICAL: Forms must be craftforms_form posts

Forms MUST be created as craftforms_form custom post type posts — never as regular pages or any other post type.

  • All form logic (_craftforms_form_meta, formula, transformations, actions) lives on a craftforms_form CPT post.

  • A regular WordPress page should never have form block HTML inserted directly via WP CLI --post_content. This does not work — the form meta is detached from the block and the form cannot function.

  • To place a form on a page, insert its synced pattern — never copy the form's block markup into the page. Saving a craftforms_form post auto-generates a wp_block (synced pattern) named CraftForms: <form title>; its ID is on the form as _craftforms_synced_pattern_id. The page holds only <!-- wp:block {"ref":PATTERN_ID,"craftformsInstanceId":"<uuid>"} /-->. Copying the form's blocks into the page forks the form: later edits never reach the copy, and per-placement Catalog linking (below) has nothing to attach to.

  • craftformsInstanceId is a stable per-placement UUID. It keys the page's _craftforms_catalog_items meta ({"<uuid>": RESOURCE_ID}), which is how a Catalog item is linked to this placement — set in the editor via the block toolbar's Catalog Item button or the document sidebar's CraftForms Catalog panel. The editor auto-assigns the UUID on first selection; when authoring by CLI, generate one yourself. The same form on two pages can therefore serve two different resources.

  • The [craftforms form=FORM_ID] shortcode is the other supported placement, and takes catalog-item=RESOURCE_ID for the same linkage (plus product= for a WooCommerce add-to-cart embed).

  • For starters: use the CraftForms import UI to import the HTML starter into a new craftforms_form post. The <!--craftforms-meta--> block in the starter file is parsed on import to set _craftforms_form_meta automatically.

  • Do not use WP CLI wp post create --post_type=page to deploy a form. Only use wp post create --post_type=craftforms_form to create the form post itself.


CRITICAL: Anything set via WP CLI MUST conform to what the editor UI produces

WP CLI bypasses the import healing and the editor's save pipeline, so it is easy to write data the UI cannot read back or that fails block validation. Three rules:

  1. Form meta must be stored as a PHP array, not a JSON string — always pass --format=json to wp post meta update (see Set form meta). A string round-trips on the frontend but is invisible in the editor's Smart Variables / Price Formula panels.

  2. Every value you put in block HTML must have a matching block-comment attribute the field's save() knows about, and vice-versa — otherwise the editor throws "Block validation failed". The canonical example is the required message on radio/checkboxes (requiredMessage ⇄ data-validate-minselected-message). Whenever you hand-write a data-validate-*-message (or any non-default field attribute) into HTML, set the corresponding block-comment attribute to the same value. The attribute names that back each validation message are listed in Validation messages.

  3. Mirror every editor-synced attribute onto the *-field wrapper, not just the inner input. See below — this one destroys data silently.

The *-field wrapper is the editor's source of truth

text-input-field / textarea-field (and the other *-field blocks) run an effect that pushes their own attributes down into the inner text-input / textarea block whenever the form is open in the editor:

These attributes have "default": "" (or 0) in block.json, so they are never undefined — the guard never fires. If you set placeholder on the inner block only, the wrapper's empty default overwrites it the first time anyone opens the form in the editor, and it is written back on the next save. Nothing warns you: WP CLI output looks right, the frontend renders correctly, and the value disappears later.

This is exactly how a form loses its placeholders on export → import: import runs through the editor, the sync fires, and the exported placeholders are gone in the imported copy while requiredMessage (not synced) survives — a confusing half-loss.

Attributes synced from the wrapper, as of this version:

Block
Synced down to the inner block

text-input-field

type, value, placeholder, required, disabled, readOnly, autoComplete, min, max, step, minlength, maxlength, pattern, list, cfMin, cfMax, cfMinlength, cfMaxlength, cfStep, cfRequired

textarea-field

value, placeholder, required, disabled, readOnly, autoComplete, rows, cols, resize, minlength, maxlength

The *Message attributes are NOT in the list — keep those on the inner block as the Validation messages section shows.

So a text field with a placeholder must carry it in both places:

To check an existing form, compare the wrapper's attributes against the table above:

After any WP CLI authoring, open the form once in the editor to confirm it loads without a validation notice, that formula/variables/messages appear in the panels — and that the field settings panel shows the values you wrote. An input whose Placeholder box is empty in the inspector but which renders a placeholder on the frontend is rule 3 about to bite.


CRITICAL: Export the form — don't hand-write the starter file

Once a form is built and verified in the editor, use the Export button rather than hand-authoring the starter HTML. In the form editor sidebar, open the Export panel and click Export Form; you get form-{id}.craftform.html in exactly the starter format described in this guide.

This matters for correctness, not just convenience. The export pipeline (Form_Importer::get_form_export_data() → render_form_file()) is a normalisation pass:

  • It repairs string meta. If _craftforms_form_meta was stored as a JSON string (the rule-1 mistake above), export decodes it and writes clean JSON into the <!--craftforms-meta--> block. The imported copy comes back as a proper array.

  • It re-serialises meta from the database, so the exported file reflects what the editor actually read — not what you hoped you wrote.

  • It strips the ref attribute from the craftforms/form block. ref is the form's own post ID and must not be carried between installs; import re-assigns it to the new post.

  • It embeds images as base64 in a <!--craftforms-assets--> block, so the file is self-contained and sideloads on import (deduplicated).

  • It sets Version: by feature detection, not by your licence — detect_form_version() returns pro if the form has a formula, transformations, conditional-logic rules, a user_registration/create_post submit action, or a craftforms/file-field. Otherwise free. A pro file is refused on import into a free install, so a form with a price formula is not portable to free regardless of where it was exported from.

Treat a successful export as the conformance check. The recommended loop for agent-authored forms:

  1. Build the form locally (abilities and/or WP CLI).

  2. Open it in the block editor — confirm no validation notice, and that formula/variables/messages appear in the panels.

  3. Export. The downloaded file is the reviewable, diffable, version-controllable deliverable.

  4. Import on the target site (or drop it into starters/forms/ to ship it as a starter).

Hand-writing the starter file skips every guarantee in that list. Build it, verify it, export it.


Overview

  • Starters are block HTML files in starters/forms/ with an embedded <!--craftforms-meta--> JSON block that the system imports into _craftforms_form_meta on the craftforms_form CPT post

  • Form meta (formula, transformations, actions) lives in _craftforms_form_meta post meta on a craftforms_form CPT post

  • The ref attribute in craftforms/form = the craftforms_form post ID

  • Infoblock uses connectedForm attribute = same form post ID; renders inner content as a live-updating template


Authoring Conventions

Rules to follow when creating CraftForms starters.

Transformation type priority

Choose the most appropriate type — in order of preference:

  1. table — for any field→value price lookup (e.g. diameter → price per foot). Preferred: data is manageable via CSV export/import in the UI without touching code.

  2. conditional — for condition-based values. Use instead of ternary chains in expressions. Can return strings, not just numbers — use for text outputs like application messages.

  3. linked — to resolve a choice field's selected option to one of its attributes (e.g. price). The PHP auto-builds the table from the field's options array at render time.

  4. expression — only when you need math functions, string operations, or a calculation that mixes multiple variables in ways the other types can't cover.

Option price attribute

For radio/checkbox fields that act as price multipliers, set a price attribute on each option. Then add a linked transformation that resolves the selected option's price into the formula pool.

Transformation:

Formula: diameter_price * material * length — material resolves to the selected option's price.

Formula — no round() needed

Price output is auto-rounded by the system. Write clean formulas without wrapping in round().

Field layout — max 2 columns

Never place more than 2 fields in a row. Standalone fields go full-width outside any grid group: radio/checkbox groups (especially card/swatch styles), textarea, range slider, repeater, file upload, booking datepicker, infoblock, and the preview blocks (product-image, layered-image, chart, image-cropper). For WC-ready forms, the qty number input and the Submit button go in the packaged Add to Cart pattern (see Add to Cart) — reuse that exact pattern rather than inventing your own layout for this pair.

Notification block placement

Keep <!-- wp:craftforms/notification /--> in its own standalone vertical flex group, separate from the submit button. This matches the rendering contract expected by the frontend.

WC-ready starters

For forms designed for WooCommerce product pages:

  • Name: the plain product name, no WC: prefix (e.g. Pipe Price Calculator) — the starter modal is shown on hosted installs without WooCommerce too. Say "WooCommerce ready." at the end of the Description: instead.

  • File name: wc-{product}.html

  • Always reuse the Add to Cart pattern for the qty input + Submit button — see Add to Cart for the exact markup and why <!-- wp:craftforms/add-to-cart /--> cannot be written directly into a starter file. Never invent a different layout for this pair from scratch.

  • Do not include qty in the price formula — WooCommerce handles quantity multiplication at cart level

  • If the form already uses qty as a radio/select for a product variant tier (e.g. print quantity), name that field something other than qty (e.g. print_qty, stkr_qty) to keep qty free for the WC cart quantity input

Transformation expose flag

Add "expose": true to a transformation to make its result available as {{field.name}} in infoblock templates. This is the canonical display path — only exposed transformations can be referenced in infoblock content.

Expose rules:

  • Expose any transformation whose result is displayed in the form (infoblock output, dynamic labels).

  • Expose any transformation used as a dynamic validation constraint (min/max on a number field).

  • Do not expose intermediate calculations that are only inputs to other transformations or the formula — these have no use in templates and cluttering the field pool with them is noise.


Complete Workflow

1. Create the craftforms_form post

CRITICAL — write "ref":$FORM_ID into the craftforms/form block yourself. The editor sets ref to the current post ID on first edit (src/blocks/form/index.js), so a form built entirely by CLI never gets one. Without it Form_Block::get_form_submission_url() has no form to resolve — it falls back to get_the_ID(), which is the page when the form renders inside a synced pattern — and returns ''. The result is a form that renders and then silently never initialises: data-action is empty, data-craftforms-key is absent, the data-craftforms-pending attribute is never removed, and the whole form stays hidden with no console error. Set it in the block HTML before creating the post:

Chicken-and-egg: create the post first to learn $FORM_ID, then wp post update the content with ref filled in. (Export strips ref again — see Export the form — so starter files never carry it.)

2. Set form meta

CRITICAL — use --format=json, never a raw JSON string. _craftforms_form_meta is registered as a REST object meta (type => object, single => true). The block editor reads it via getEditedPostAttribute('meta')._craftforms_form_meta, and WordPress's REST layer only exposes object meta when it is stored as a real (serialized) PHP array. If you pass a raw JSON string, wp post meta update stores the literal string; the frontend still computes price (PHP json_decodes it), but the editor UI shows no formula and no smart variables because the string fails the object schema and REST returns it empty. Always add --format=json so WP-CLI decodes the JSON and stores an array:

To verify it stored correctly (must print array, not string):

If an existing form was saved as a string, repair it:

3. Create the starter HTML file

File: starters/forms/my-form.html

Header format:

Goals: and Industries: decide when the starter modal recommends this starter (see includes/class-business-profile.php). Both take comma-separated keys; unknown keys are dropped, and a starter without Goals: never appears under "Recommended for you".

  • Goals: leads, bookings, calculator, events, collect

  • Industries: rentals, accommodation, events, print, custom-goods, services, software, agency, other — or any for a starter that suits every business (contact, feedback, survey)

Package headers (setup wizard)

The Getting Started wizard builds a "package" from one form starter plus companions it names by slug — each companion stays an ordinary starter that its own picker still offers on its own:

  • UUID: stable identity, so re-importing updates the same form instead of piling up copies. Required when a catalog bundle points back at this starter.

  • Delivery: where the form can run — wordpress, embed, woocommerce. Missing means wordpress, embed. A form written for a product page (Add to Cart) lists woocommerce alone and is offered through the WooCommerce product starters instead.

  • Email-Template: slug in starters/email-templates/, offered as the branded notification.

  • Pdf-Template: slug in starters/pdf-templates/, offered as an attachment on that email (so it needs Email-Template: too).

  • Catalog-Item: slug in starters/catalog-items/. A booking form (one with a booking-datepicker field) must name one — the wizard hides booking starters that have no catalog item, because a booking field without one silently falls back to its block defaults.

A catalog-item bundle names its form the other way round, with Form: <form starter slug> in its own header, and carries no form of its own. Email and PDF starters take Goals:/Industries: as well, which drive the filters in their own starter pickers.

Immediately after the header, embed the form meta as a <!--craftforms-meta--> block. This is parsed by the system when the starter is imported — it sets _craftforms_form_meta automatically, so WP CLI Step 2 is not needed for starters:

Then the block HTML starting with <!-- wp:craftforms/form ... -->.

4. Create a demo page

Reference the form's synced pattern — never paste the form's blocks into the page (see Forms must be craftforms_form posts).

Verify the page actually wired up — an empty data-action means the form block is missing its ref (see step 1):


Block HTML Structure Patterns

Form wrapper

Text input field

For type variants: add "type":"email" or "type":"number" to both text-input-field and text-input.

The same "both blocks" rule applies to placeholder, value, autoComplete, min/max/step, minlength/maxlength, pattern and the cf* attributes — the wrapper syncs all of them down and will wipe an inner-only value the first time the form is opened in the editor. See The *-field wrapper is the editor's source of truth. The *Message attributes stay on the inner block only.

min/max on text-input are static values only (a plain number string, e.g. "min":"1"). Never put a transformation name in these — that is not what the Validation panel is for, and it won't show up as a bound smart variable anywhere in the editor UI. For a constraint that must track a transformation (e.g. a max width looked up from a table), use the cfMin/cfMax attributes instead — see Dynamic Validation:

Textarea field

rows, cols, resize, placeholder and value must be set on both blocks — textarea-field syncs them down and overwrites an inner-only value once the form is opened in the editor. See The *-field wrapper is the editor's source of truth.

Select field

Note: options array must be duplicated on both select-field and select blocks.

Radio field

STRUCTURE REBUILT — the old <label><input>…</label> option markup is gone. Each radio-option / checkbox-option is now an InnerBlocks container whose only child is a choice-label block holding the visible (rich) label. The <input> is not in the saved HTML at all. On the frontend, radio-field / checkboxes-field are dynamic, PHP-rendered, PRO-gated blocks: PHP regenerates every input/swatch/card from the field's options[] array, using each option's choice-label inner HTML as the visible label (matched by value). So:

  • The options[] array on the field block is the source of truth for the frontend (value, label, price, color, image, description, annotation, checked). Always populate it fully.

  • The inner option blocks + choice-label exist for the editor (block validation + rich-label editing). Author them correctly so the form opens without a validation notice.

  • choice-label carries its text as body HTML (<span class="wp-block-craftforms-choice-label cf-choice-label">…</span>), not as a comment attribute — supports bold/italic/links.

Don't copy option markup from older files. The legacy <label class="wp-block-craftforms-checkbox-option"><input …></label> option markup (no choice-label) is still accepted by the blocks' deprecations, so an old form opens without a validation notice — but markup copied from it into a new form (with a current-style field wrapper, or without one) fails validation: "This block contains unexpected or invalid content" on the checkboxes-field / radio-field. The shipped starters were all refreshed to the current markup on 2026-10-02; anything older (customer exports, old docs, other sites) may still carry it. Always copy from the examples in this guide, or from a form exported after being saved in the current editor.

Refreshing an old file: import it, open it in the editor, and take wp.blocks.serialize(wp.data.select('core/block-editor').getBlocks()) from the console — not getEditedPostContent(), which returns the original text unchanged when nothing else marked the post dirty (deprecation migrations alone don't). Then strip the form block's ref / instanceId (site-specific) before saving it as a starter, and confirm check 6 on a fresh import.

Critical: radio-option, checkbox-option, choice-label, and options-group all have non-null save(). They MUST include their rendered HTML — never use self-closing comments (/-->) for these blocks. label and form-error, by contrast, are dynamic (save() returns null) and are self-closing.

CRITICAL — block-comment attributes MUST match the rendered HTML (block validation): The field wrapper <div> and the options-group <div> are regenerated by save() from their attributes. Two rules that bite:

  1. Required message — keep three in lockstep: "required":true ⇄ data-validate-minselected="1", and "requiredMessage":"<msg>" ⇄ data-validate-minselected-message="<msg>" (exact same string). "required":true alone produces only data-validate-minselected="1", never the message. Same rule applies to checkboxes-field.

  2. Layout classes — options-group no longer hardcodes is-layout-flex is-vertical; layout is driven by the block's own layout support. Whatever layout class you put on the <div> must come from a matching "className" (and/or "layout") attribute in the comment. The safe vertical default is {"className":"is-layout-flex is-vertical"} ⇄ class="wp-block-craftforms-options-group craftforms-options-group is-layout-flex is-vertical". The field wrapper takes no layout class unless you add a matching "className".

When required=false (no required attribute): omit "required" / "requiredMessage" from the block comment and omit the data-validate-minselected / data-validate-minselected-message attributes from the wrapper div — keep the comment and HTML in sync in both directions.

Checkboxes field

Same container/choice-label structure as radio. The input [] suffix is applied by PHP at render time — you don't write <input> markup at all.

Notes:

  • name on each option block must match the field name. The input name="addons[]" suffix is added by PHP — not in your HTML.

  • options array must be repeated on the field block comment attributes — it is the frontend source of truth.

  • choice-label content lives in the <span> body, not the comment JSON. For a plain label the comment is bare (<!-- wp:craftforms/choice-label -->); the same plain text is mirrored into the option's "label" attribute for validation/catalog/conditional-logic/submission readability.

  • The field wrapper div class is wp-block-craftforms-{radio,checkboxes}-field (+ cf-field-card when optionStyle is card, + any layout class from a matching "className").

  • The same optionStyle / optionShape set on the field should be repeated on each option block (see Option styles & rich options).

Option styles & rich options

Radio and checkbox options support several visual styles plus rich per-option data. Set optionStyle (and, for swatches, optionShape) on the field block and repeat the same optionStyle on every option block. The per-option visual data lives in the field's options[] array; PHP renders it.

optionStyle

Renders as

Per-option data used

default

Standard radio/checkbox + text label (the choice-label)

label (rich), annotation

text

Text "pill" swatch (button-like selectable label)

label, annotation (can sit inside the pill via field's textSwatchAnnotationInside)

color

Color swatch chip

color (hex), label (tooltip)

image

Image swatch chip

image {id,url,alt}, label (tooltip)

card

Card with image, title, description, price

image, label (title), description, price

Per-option attributes (set in the field's options[] and mirrored onto the matching option block):

  • price — number; combine with a linked transformation (lookupColumn:"price") to fold into the formula. This is the canonical product-configurator pattern.

  • color — hex string for color style.

  • image — {"id":123,"url":"https://…","alt":""} for image / card styles.

  • description — short line shown under the card title (card style).

  • annotation — live Mustache string (see Option annotations).

  • checked — preselect (checkboxes); for radio use the field's defaultValue.

Field-level swatch/card sizing & styling attributes: optionSwatchWidth / optionSwatchHeight, optionCardImgWidth / optionCardImgHeight, optionFontSize, and the textSwatch* group (textSwatchFontSize, textSwatchColor, textSwatchBackground, textSwatchColorChecked, textSwatchBackgroundChecked, textSwatchBorder*, textSwatchPaddingV/H, textSwatchAnnotationInside) plus field border (fieldBorderWidth/Style/Color/Radius).

optionSwatchWidth/optionSwatchHeight are NOT optional for color/image swatches — they default to 0, which PHP renders as CSS width:auto;height:auto on an empty <span class="cf-swatch-visual">. An empty auto-sized span collapses to a near-invisible sliver (no intrinsic content to give it size), not a full-size chip. Always set an explicit pixel size (e.g. 48) on any field using optionStyle:"color" or optionStyle:"image", or the swatches will render as tiny unclickable dots. (text-style swatches are less affected since the pill has label text giving it intrinsic width, but optionSwatchHeight is still worth setting for a consistent row height.)

Colour swatch example:

optionSwatchWidth/optionSwatchHeight are read from the field block only (PHP never looks at them on the option blocks) — no need to repeat them per-option the way optionStyle/color/image must be.

Card-style product configurator example (image + title + description + price per option). The option block still wraps a choice-label for the editor; PHP renders the card on the frontend from the options[] data:

Notes:

  • The field wrapper gets cf-field-card automatically when optionStyle is card — include it in the wrapper class (matches save()).

  • The card's price comes from the option's price; to add it to the form total, define a linked transformation on the field (lookupColumn:"price") and use it in the formula — exactly the existing option-price pattern.

  • Rich labels: put bold/italic/links inside the choice-label span; the plain-text version is mirrored to the option's "label" for matching.

Option annotations

Each radio or checkbox option can carry an annotation — a Mustache template string rendered live beside the option label as form values change. Use it to show dynamic per-option data such as tier prices, discounts, stock, or any transformation result.

Add "annotation" to the option object in the options array on the field block:

Put the annotation in both the field's options[] entry and the matching option block's comment. You do not write the annotation <span> in the HTML — PHP renders the .cf-option-annotation[data-cf-template] span at frontend, and the JS runtime resolves it live on every form state change. The option block stays the standard container + choice-label:

Options without an annotation omit it entirely.

Variables available in annotations — same pool as infoblocks:

  • {{form.currency}}, {{form.price}} — form-level values

  • {{transformations.name}} — any transformation result

  • {{field.name}} — fields and exposed transformations

Quantity-tier pricing example (sticker-qty pattern):

This pattern powers a quantity selector where each tier shows its total price and discount vs. the base tier:

  1. One table transformation per tier, keyed by a size/variant field:

  2. One expression transformation per tier for the discount percentage:

  3. Annotation on the option: "{{form.currency}}{{price_100}} · Discount {{discount_100}}%"

As the user changes an upstream field (e.g. size), all tier price and discount annotations update live without requiring any infoblock.

Infoblock (live price / dynamic display)

Template variables update live as the user fills the form.

Conditional or long messages — use a transformation, not inline logic in the infoblock. When a displayed message is conditional (e.g. show only when discount > 0, or varies by field value), create a conditional transformation with "expose": true that returns the full message string or empty string, and reference it as {{field.name}} in the infoblock. This keeps the infoblock HTML clean and the logic testable in the meta JSON.

In the infoblock, just: {{field.discount_msg}} — renders empty string when the condition isn't met, so no stray text or placeholders appear.

Two-column responsive layout

Never exceed 2 fields per row. Wrap pairs of fields in a wp:group with "type":"grid" layout. WordPress's CSS grid auto-fill creates 2 columns when space allows, stacks to 1 column on mobile. The minimumColumnWidth controls the breakpoint.

Fields that should span full width (radio groups, textarea, infoblock) go outside the group as standalone blocks.

Error message styling

Apply on every craftforms/form-error block to make errors red and small:

Submit group

The submit button and notification block must be in separate groups:

For WC-ready forms, use the Add to Cart pattern for the qty + Submit pair — see Add to Cart for the exact markup to reuse (it's the same pattern the add-to-cart block itself expands into; don't invent your own layout for this pair):


More Field Blocks

These are newer input field blocks. They all follow the same wrapper-less pattern as text-input-field: the *-field block's save() returns just <InnerBlocks.Content /> (no wrapper <div> to hand-write), and the inner input block (range-slider, color-picker, file, booking-datepicker) is dynamic / PHP-rendered, so it is self-closing (/-->). Compose them as: *-field comment → label (/-->) → inner input (/-->) → form-error (/-->) → close. Most are PRO. Validation messages and dynamic-validation cf* attributes work as described in their own sections.

Range Slider

Single- or double-handle numeric slider. The field value is a number (single) or a from/to pair (double). Great for budget, length, capacity, or min/max range inputs that feed the price formula.

  • Key attributes (repeat on both -field and inner block): min, max, step, round, doubleHandles, defaultValue (single) / defaultValueFrom + defaultValueTo (double), showTooltip, tooltipUnits, showMarks. Styling: trackFillColor, trackBgColor, handleColor, handleSize, marksColor, marksFontSize.

  • Double handles: set "doubleHandles":true; the value resolves to a range — reference budget in transformations/formula per the runtime's range handling.

  • Dynamic constraints: cfMin / cfMax (expression) and cfRequired are supported.

Color Picker

Color input with optional preset swatches. Value is a color string in the chosen format (hex / rgb / hsl).

  • swatches is an array of preset colors; hideInput:true hides the free-form input (swatches only). Pairs well with a layered-image recolor layer.

File Upload

File input with size/type/count validation. Uses its own "Error Messages" panel attributes (error* naming).

  • allowedTypes are MIME types; maxSizeMB is per file; maxFiles only applies when multiple:true.

Booking Datepicker

Date / date-range / time-slot picker (HotelDatepicker). The cornerstone of bookings, rentals, and appointment forms. PHP-rendered.

  • formFieldType: dates-range | single-timeslots | seasonal | continuous-range — these are the only four values the Form Field Type dropdown offers, and single-timeslots (not single-date) is the appointment/time-slot mode. bookingType is the legacy twin kept in sync by the editor; author both. The mapping is dates-range→day-based, single-timeslots→fixed-slots, seasonal→seasonal, continuous-range→continuous-range. bookingType: day-based (nights) | fixed-slots (slots via weeklySchedule + slotInterval) | flexible-range (per-date custom_slots) | seasonal (slot times vary by season — see below) | continuous-range (freeform start + multi-slot duration, e.g. meeting rooms — see below).

  • Constraints: minNights / maxNights, startDate / endDate, disabledDaysOfWeek, noCheckinDays / noCheckoutDays, selectForward, advanceNotice, startOfWeek, lang, format.

  • What a date selection puts in the pool (the field value is an object, flattened to dotted keys — there is no bare numeric alias): day-based → field.checkin + field.checkout (date strings); fixed-slots / seasonal → field.date + field.time (time is HH:mm); continuous-range → field.date + field.start + field.end + field.count (see below). Nights are not auto-injected — compute with count_nights(field.checkin, field.checkout). To derive a month/hour/etc. from the date, use the date-part functions below.

  • Combine with dynamic validation to cap quantity at remaining capacity ("cfMax":"_booking_capacity").

Continuous duration (formFieldType:"continuous-range") — PRO

Use when the customer books one or more consecutive slots with no internal gap instead of a single fixed appointment — meeting rooms, studios, courts. The customer picks a start time, then a duration (a slot count); the two combined must be a continuous block of count × slotInterval minutes, e.g. three 30-minute slots starting 9:00 books 9:00–10:30 as one uninterrupted booking.

  • weeklySchedule entries support an optional break_from/break_to (e.g. lunch) — a hard closed window, distinct from the turnaround buffer below. slotInterval is the bookable unit in minutes; minSlotCount/maxSlotCount bound how many consecutive units one booking may span (maxSlotCount:0 = unlimited, capped only by remaining open time).

  • Pricing: field.count is the number of priced slot-units — key your formula on it directly, e.g. field.count * 15 for a €15/30-min room. There's no separate duration-in-minutes var; derive it if needed with field.count * <slotInterval>.

  • Free vs catalog: without a catalog item, start-time/duration options are generated client-side from weeklySchedule alone (no real availability enforcement — nothing to book against). Link a booking catalog item (booking_type:"continuous-range", with slot_interval, min_slot_count, max_slot_count, turnaround_buffer) to get real interval-overlap-checked availability — the catalog's schedule then overrides the block's, and a dedicated craftforms_inventory_ranges table (not the discrete-slot counters used by fixed-slots/seasonal) tracks booked [start_time, end_time) intervals per resource/date.

  • Turnaround buffer (turnaround_buffer, catalog-only — there's no block-level attribute for it, since it only matters against other bookings, which the no-catalog client-side mode has no visibility into): the minimum gap required between any two different bookings that day. It is never applied inside one customer's own consecutive slots — 3×30min books as one unbroken 90-minute block. If a booking ends at 10:30 and the buffer is 15 minutes, the next customer's earliest valid start is 10:45, not the next slot-interval boundary from opening time — free-window start times are anchored to when the previous booking's buffer clears, not a fixed clock grid.

  • Occupancy is always exclusive/single-resource for this mode — there's no capacity/occupancy_model concept like the other appointment types (one meeting room = one booking at a time).

Seasonal time slots (formFieldType:"seasonal")

Use when the selectable slot start-times change by the date's season (e.g. rentals/tours: Apr–May slots 10:00 & 14:00; Jun 09:30 & 14:00; Jul–Aug 08:30 / 12:00 / 15:30). A season is a recurring MM-DD range that owns a per-weekday list of explicit start-times. It carries only the schedule — pricing stays separate (key a table on month(field.date), see below).

  • season shape: { id, label, from:"MM-DD", to:"MM-DD", schedule:[{day:0-6, enabled, times:["HH:mm",…]}] }. day is 0=Sun … 6=Sat. to < from wraps the year-end. Populate a schedule entry for every weekday you want bookable (list them all — one per day).

  • Repeat the full seasons array on both the -field and inner booking-datepicker blocks (same source-of-truth rule as weeklySchedule).

  • Dates matching no season render disabled (no slots). First matching season wins — keep ranges non-overlapping.

  • Free vs catalog: without a catalog item, slots are generated client-side from the seasons attribute (no capacity). Link a booking catalog item (booking_type:"seasonal", with capacity, occupancy_model, capacity_measure_var) to get real per-date+slot inventory — the catalog's seasons then override the block's. Inventory reuses the standard craftforms_inventory_slots counters (per date + time_slot).

Date/time formula functions

Available in formula and in expression / conditional transformations (and, once materialized as a pool key, as a table rowVar/colVar). All parse both YYYY-MM-DD and DD/MM/YYYY; JS preview and PHP re-eval agree.

Function
Input
Returns

month(dateStr)

date

month 1-12 (0 invalid)

year(dateStr)

date

full year e.g. 2026

day(dateStr)

date

day of month 1-31

week_day(dateStr)

date

day of week 0-6 (0=Sun)

hour(timeStr)

HH:mm slot time or datetime

hour 0-23

count_nights(ci, co)

two dates

nights between

is_date_between(d, "start:end")

date + range

bool

is_time_between(t, "start-end")

time + range

bool

Seasonal / per-month pricing pattern (prices detached from the schedule): materialize the month, then look it up in a per-month table.

Formula: boat_price * qty. Because table rowVar must be a pool key (not an inline expression), always compute month(...) into its own expression transformation first, then key the table on that name. The same two-step works for week_day (weekday/weekend pricing), hour(booking.time) (time-of-day), or year+month+day (specific dates).

Repeater

A repeatable group of fields the visitor can add/remove (line items, attendees, custom rows). The repeater-field wraps a single repeater-group template; blocks placed inside the group are cloned per row on the frontend.

  • minItems / maxItems, startEmpty, and add/remove button text/icon/SVG + styling are all attributes on repeater-field.

  • Repeated field values arrive as arrays keyed by the repeater name; sum or count them with table/expression transformations as needed.

Select — multiple

select / select-field are unchanged structurally (inline options array on both blocks, no child option blocks). New: "multiple":true for a multi-select whose value is an array (validate with any-of/all-of/none-of, same as checkboxes).


Commerce, Payment & Preview Blocks

Higher-level blocks for WooCommerce, payments, and live product visualization. Most are PRO. Several connect to a form via a connectedForm (form post ID) attribute and read its live field values / smart variables.

Add to Cart

Convenience inserter shortcut for the Quantity field + Add-to-Cart button pair on a WooCommerce product form. It only exists as an editor UX helper — the moment you insert it in the block editor, its edit() immediately expands into the constituent blocks below and removes itself (save() returns null, and there is no PHP render_callback). This means:

  • In the block editor UI: inserting the "Add to Cart" block is the fastest way to get this pattern — use it there.

  • In starter HTML files, WP-CLI-authored content, or anywhere else you hand-write block comments: writing <!-- wp:craftforms/add-to-cart /--> literally does nothing (renders blank — confirmed via do_blocks(), no button, no qty field). You must author the expanded pattern directly instead — this is exactly what the block itself expands into (TEMPLATE in src/blocks/add-to-cart/index.js), so it is always correct to reuse verbatim:

Always reuse this exact pattern for every WC-ready starter's qty+submit pair — never invent a different layout for it. qty is still not in the price formula — WooCommerce multiplies at cart level.

Payment

Enables online payment (Stripe / PayPal) for a non-WooCommerce form. Keys live on Payment Connections — named credential sets managed under CraftForms → Settings → Payments, each carrying exactly one key pair (make a separate connection for Test and Live). The block selects which connection to use and how the payment UI is presented.

  • mode: checkout (redirect to the gateway's hosted page, then back to the success URL) or elements (card fields / buttons inline on the form). Not a gateway name — {"mode":"stripe"} is an obsolete shape.

  • stripeConnectionId / paypalConnectionId: the post ID of a published craftforms_pay_conn. Both are optional and independent — set both and the customer chooses on the form; set neither and the block does not render at all on the frontend.

  • customerMapping: {email, name} → field names, so the gateway receives a real customer identity and can send its own receipt.

  • Currency and number format are site-wide, under Settings → Ecommerce (not per block). Success/cancel redirect URLs are under Settings → Payments.

  • No submit action is needed for the order. When a Catalog item is linked to the placement, create_order is auto-appended ahead of your configured actions — it creates the order, reserves booking/stock inventory, then captures payment, cancelling the order if the reservation loses a race.

Order Summary

Renders a CraftForms order on the payment success page. Reads the order reference from the cf_order URL parameter — place it on the page the gateway redirects to (not inside the form).

Product Image

Image viewer (thumbnails, zoom) whose displayed image(s) react to form choices via image conditional logic — the visual counterpart to field conditional logic. Connect it to the form with connectedForm.

  • images is the gallery; imageConditionalLogic maps field conditions → which image(s) to show (built in the editor UI).

Layered Image

Composites a background plus PNG layers driven by form options — swap a layer's image or recolor a layer shape per choice, producing a live product preview that can be saved to the order. Ideal for configurators (apparel, signage, packaging).

  • Each layer binds to a form field (typically a radio/select with image or color options): the selected option's image swaps the layer, or its color recolors the layer shape.

  • saveOnSubmit + saveResultField persists the composited preview into a form field saved with the submission/order.

Image Cropper

Interactive crop (aspect-locked to form width/height smart variables), flip, and B&W / sepia filters; the result is saved with the submission. PRO.

  • varWidth / varHeight reference form variables that drive the crop aspect ratio; targetField receives the result.

Chart

Live pie / donut chart driven by the connected form's field values and smart variables. Purely presentational. PRO.

  • dataPoints map labels to transformation/field values (e.g. a price breakdown). chartType: pie | donut (with donutWidth).

QR Code / Barcode

Renders a QR code or barcode into a generated PDF (see the PDF builder). The value may contain merge tags so each document is unique. Place inside a PDF template, not a web form.

  • symbology: qrcode, code128, ean13, etc. ecLevel is QR error correction; barHeight applies to 1D barcodes; color / bgColor / align style it.


Form Meta Schema Reference

Transformation types

table — lookup a value from a table by field value

For a 1D lookup (field → value), the header row is ["", "col_name"] where the empty string is the corner cell and col_name is an internal identifier. Set colVar and rowVar to the same field name.

For a 2D lookup (row field × column field → value), the header row lists the column field's possible values:

lookupStrategy can be "exact", "closest-up" (smallest value ≥ input, useful for tiered pricing), or "closest" (nearest value).

linked — auto-build a table from a choice field's options

Used to resolve a radio/select/checkbox field's selected value to one of its option attributes (commonly price). The PHP builds the lookup table from the field's options array at render time, including any catalog overrides.

lookupColumn is the option attribute to return — "price" or "label". The transformation name is typically the same as linkedField so the formula uses it directly.

conditional — return a value based on conditions

Use instead of a ternary-chain expression when selecting from discrete values based on field state. Conditions are evaluated top-to-bottom; the first truthy when wins. Can return strings as well as numbers.

String output example (application message):

  • when and value are Jexl expressions evaluated against the current field pool

  • conditionDefault is a Jexl expression evaluated when no condition matches (optional)

  • defValue is the static fallback when conditionDefault is empty or returns null

expression — evaluate a Jexl expression

Use only when you need math functions, string operations, or a calculation mixing multiple variables that table or conditional can't cover.

For checkboxes (array values), use "value" in fieldname syntax.

Dynamic Validation — expression-driven constraints

Validation constraints (min, max, minlength, maxlength, step, required) can be driven by smart variables or expressions that resolve against the live resolvedDataPool on every form state change, so constraints update dynamically as the user fills the form.

Plain min/max/minlength/maxlength/step (the "Validation" panel) are for static values only — a literal number, nothing else. Any transformation-driven or expression-driven constraint always goes through the separate cf* attributes below (the "Dynamic Validation" panel). Do not put a transformation name in the plain min/max attribute — even though the runtime happens to still resolve it there for backward compatibility with older forms, it is not the authoring convention: it won't be recognized as a bound smart variable anywhere in the editor UI, and it mixes static/dynamic intent on one attribute. Always use cfMin/cfMax for a transformation-driven constraint on new forms.

data-cf-* expression attributes (the only correct way to bind a transformation to a constraint)

Use the Dynamic Validation inspector panel (separate from the Validation panel) to set expressions for any constraint. Expressions are stored in data-cf-* HTML attributes (data-cf-min, data-cf-max, data-cf-minlength, data-cf-maxlength, data-cf-step, data-cf-required) and resolved at runtime. The resolved value is written back to the native HTML attribute before each validation pass, so browser constraint validation picks it up natively.

All transformation results are flat top-level keys in resolvedDataPool by their name. The resolution order for a cf* expression is:

  1. Mustache render: "cfMax":"{{max_width_cm}}" → renders to value then parses as float

  2. Direct pool key: "cfMax":"max_width_cm" → resolves pool['max_width_cm'] (preferred — cleaner)

  3. Literal number: "cfMax":"153" → uses 153 as-is

In block HTML — write the transformation name as the cf* attribute string on the text-input block:

Setting via editor UI: In the Dynamic Validation panel (not the Validation panel), the Max Value (expression) input accepts plain text — type the transformation name directly (e.g. max_width_cm).

Range Slider — min/max vs validateMin/validateMax: on the range-slider field, min/max are the slider track endpoints (the visible/draggable range), not the validation constraint. To constrain the accepted value separately, set validateMin / validateMax on the range-slider-field (they fall back to min/max when empty and are emitted as data-validate-min / data-validate-max). For dynamic constraints use cfMin / cfMax on the inner range-slider.

Supported expression syntax:

  • Pool key: my_transformation → resolves resolvedDataPool['my_transformation']

  • Mustache: {{my_transformation}} → same result via mustache render

  • Dot-path: form.booking_capacity → nested resolution

  • Expression (for required only): field1 == "option" → evaluated via Jexl

In block JSON — use cf* attributes on the text-input block:

In editor UI: Open the "Dynamic Validation" panel (separate from the Validation panel). Leave any expression blank to fall back to the static Validation value. Which cf* attributes a field actually supports (only these exist on the block — using any other cf* is silently ignored):

Field

Supported cf* attributes

text-input(-field)

cfMin, cfMax, cfMinlength, cfMaxlength, cfStep, cfRequired

range-slider(-field)

cfMin, cfMax, cfRequired

select(-field)

cfRequired

color-picker(-field)

cfRequired

radio-field

cfRequired

checkboxes-field

cfRequired

textarea, file, and booking-datepicker have NO dynamic-validation (cf*) attributes — do not author them. textarea supports only static minlength/maxlength + custom messages; file uses its own size/type/count limits; booking uses minNights/maxNights etc.

required expressions: A truthy resolved value makes the field required; falsy makes it optional. Supported on text-input, range-slider, color-picker, select, radio, and checkboxes (the fields with cfRequired above).

Error message: for min/max, the native browser error message reads the resolved value from the DOM attribute — no extra hint block needed.

Important: The transformations.name dot-path syntax is only valid in infoblock Mustache templates. In min/max attributes and data-cf-* expressions, use the transformation name directly without a namespace prefix.

Validation messages

Every field can carry custom error messages per validation rule. In the editor these are set in the "Validation Messages" inspector panel (file-field uses its own "Error Messages" panel). In block HTML / WP CLI you set them as block-comment attributes that the field emits as data-validate-<rule>-message on the rendered element; the runtime reads those and overrides the default message. Mustache vars are supported (e.g. {{min}}, {{max}}, field names).

Field
Rules with a custom message
Block-comment attribute(s)

text-input(-field)

required, minlength, maxlength, min, max, pattern, email

requiredMessage, minlengthMessage, maxlengthMessage, minMessage, maxMessage, patternMessage, emailMessage

textarea(-field)

required, minlength, maxlength

requiredMessage, minlengthMessage, maxlengthMessage

select(-field)

required

requiredMessage

radio-field

required

requiredMessage

checkboxes-field

required, min (minselected), max (maxselected)

requiredMessage, minMessage, maxMessage

booking-datepicker-field

required

requiredMessage

file-field

required, file size, file type, max files

errorRequired, errorMaxSize, errorFileTypes, errorMaxFiles (note the error* naming)

WP CLI / hand-authored HTML must conform to the UI (block validation). radio-field and checkboxes-field emit a wrapper <div> from their save() (regenerated from the block-comment attributes), so a message that appears only in the HTML (e.g. data-validate-minselected-message="…") but not in the comment (requiredMessage/minMessage/maxMessage) will fail validation. Always set both, with identical strings. (These fields are PHP-rendered on the frontend, but the editor still validates the saved wrapper.) For the wrapper-less fields — text-input, textarea, select, range-slider, color-picker, file, booking — the message only needs to be in the block-comment JSON; the runtime/PHP render emits the matching data-validate-*-message for you. Mapping for those:

  • requiredMessage → data-validate-required-message

  • minlengthMessage → data-validate-minlength-message, maxlengthMessage → data-validate-maxlength-message

  • minMessage → data-validate-min-message, maxMessage → data-validate-max-message

  • patternMessage → data-validate-pattern-message, emailMessage → data-validate-email-message

Example — text input with custom messages (the data-validate-* attributes are emitted by PHP, so you only write the comment JSON):

Example — required radio (static block: set the message in both places):

Formula syntax

The formula field is a Jexl expression. You can reference:

  • Raw field names (e.g., pages, price_base)

  • Transformation names (e.g., service_rate, turnaround_multiplier)

  • Standard arithmetic: +, -, *, /, ()

  • Functions incl. round, ceil, floor, min, max, count, sum, and the date/time helpers month / year / day / week_day / hour / count_nights / is_date_between / is_time_between (see Date/time formula functions)

Decimal literals — avoid trailing zeros after the decimal point. 45 causes a Jexl parse error. Use a plain integer (45) or a real decimal with at least one non-zero digit after the point (45.50). Never write 45, 1.00, 0.50 — always 45, 1, 0.5.

Example:

Ternary for clamping (e.g. minimum height 1m from cm input):

Submit actions

save_submission

send_email


Conditional Logic Rules

Pro feature. Rules stored in the conditionalLogicRules array inside _craftforms_form_meta. Evaluated on every form state change; hidden fields are excluded from the resolvedDataPool (their values don't affect transformations or price).

Rule schema

Author each rule with the full canonical key set — exactly the shape the editor UI emits. Include cssSelector, targetField, targetOptions, conditionValue, and conditionValues even when empty, so the rule round-trips cleanly through the Conditional Logic editor:

Prefer the positive show action

State the rule the way you'd describe it: "show the engraving text field when engraving is selected." Use show + a positive operator.

  • ✅ Good: "action": "show", "operator": "any-of" (checkbox) / "one-of" (radio) → compiles to a clean positive expression like includes('yes', engraving) or engraving == 'yes'.

  • ❌ Avoid: "action": "hide" + a negative operator (none-of/not-equals). This compiles to a double negative such as !(!includes('yes', engraving)), which is harder to read and easy to get wrong.

Both can produce the same end state, but the positive show form is what the editor generates, is self-documenting, and avoids the double-negative trap.

Action types

Action
Key(s) used
Applies to

show / hide

targets (array of field names)

Entire fields

enable / disable

targets (array of field names)

Entire fields

show-element / hide-element

cssSelector (CSS selector string)

Any DOM element on the page

show-option / hide-option

targetField (field name) + targetOptions (array of option values)

Individual radio/checkbox/select options

Operators

Operator
Condition
Value key
Field type

equals

field == value

conditionValue

single-value (radio/select/text)

not-equals

field != value

conditionValue

single-value

is-empty

field has no value

—

any

is-not-empty

field has a value

—

any

one-of

field equals one of several

conditionValues (array)

single-value

any-of

array field contains any of

conditionValues (array)

multi-value (checkbox)

all-of

array field contains all of

conditionValues (array)

multi-value (checkbox)

none-of

array field contains none of

conditionValues (array)

multi-value (checkbox)

CRITICAL: match the operator to the condition field's type

The operator must match how the runtime stores the field's value, or the rule silently never fires:

  • Single-value fields (radio, select, text/number) store a scalar string. Use equals / not-equals / one-of. These compile to equality (engraving == 'yes').

  • Multi-value fields (checkboxes) store an array (e.g. ["yes"], or [] when unchecked). Use any-of / all-of / none-of. These compile to membership (includes('yes', engraving)).

Common mistake: using one-of on a checkbox condition field. It compiles to engraving == 'yes', but the value is the array ["yes"], so ["yes"] == 'yes' is always false and the rule never triggers. For a checkbox, use any-of. (For the same reason, a transformation testing a checkbox uses array syntax: "yes" in engraving.)

Starters must include the compiled runtime maps

conditionalLogicRules is the editor's source of truth, but the frontend runtime does not read it directly. On save, the editor compiles the rules into three flat maps the runtime actually consumes:

Map
Built from actions
Key format
Value

displayFieldsLogic

show / hide / show-element / hide-element

CSS selector (e.g. [data-craftforms-field="engraving_text"])

Jexl expression; truthy = visible

disableFieldsLogic

enable / disable

field:name

Jexl expression; truthy = disabled

displayOptionsLogic

show-option / hide-option

fieldName:optionValue

Jexl expression; truthy = visible

Because a starter is imported as raw meta (not re-saved through the editor first), you must hand-write both conditionalLogicRules and the matching compiled map(s), kept in sync. How each rule compiles (see buildRuntimeLogic in ConditionalLogicModal.js):

  • show + any-of ["yes"] on engraving → "[data-craftforms-field=\"engraving_text\"]": "includes('yes', engraving)"

  • show + one-of ["yes"] on a radio → "[data-craftforms-field=\"…\"]": "engraving == 'yes'"

  • hide + same condition → the expression is wrapped in !( … ) (the double-negative to avoid)

Example: filter dependent field options based on another field

Show only compatible privacy level options based on the selected glass type. Use hide-option rules for each incompatible combination:

When a currently-selected option becomes hidden, its value is automatically cleared and the field re-validates. The rules run on every field change until all conditions stabilize (convergence loop, max 100 iterations).

Note on Pro gating

The conditional logic editor UI is a Pro feature. For starters, write conditionalLogicRules directly in the form meta JSON — the runtime engine executes them regardless of license.


Template Variable Reference

In infoblock content

Infoblocks render with Mustache templating. The event data object passed on every form change has these namespaces:

Variable
Description

{{form.price}}

Computed formula result, already formatted per the site's Number format setting

{{form.currency}}

Site currency symbol (e.g. €)

{{form.price_incl_tax}} / {{form.price_excl_tax}} / {{form.tax_amount}}

Tax-aware variants (WooCommerce product pages; equal to form.price elsewhere)

{{field.fieldname}}

Current value of a form field

{{field.name}}

Result of a transformation that has "expose": true — canonical display path

{{fieldname}}

Shorthand for {{field.fieldname}} — works, but {{field.fieldname}} is canonical

There is no {{form.priceFormatted}}. _getDataForEvent() in craftforms-form.js builds the form namespace from exactly the keys above; form.price has already been run through the site's Number format, so it is the formatted price. {{form.priceFormatted}} renders as literal text. (The hint under Settings → Ecommerce still advertises the old name — it is wrong.)

{{field.name}} is the only supported path for displaying transformation results in infoblocks. The transformation must have "expose": true. Use it for all lookup results — VLT %, warranty text, film codes, application messages, computed lengths, etc.

Optimization — option values as display values: When a radio/select field's value will be displayed directly in the infoblock (e.g. a privacy percentage), set the option value to the display value itself (e.g. "47", "65", "82" instead of "privacy_47"). Then {{field.privacy}} renders the display-ready string directly, with no extra transformation needed. Avoid creating a table transformation just to map option values to labels when the values can be the labels.

Important syntax difference:

  • In infoblock Mustache: {{field.max_width_cm}} (transformation must have expose: true) ✓

  • In cfMin/cfMax HTML attributes on number fields: use max_width_cm (bare name, uses form pool) ✓

In email settings

Variable
Description

{{email.fieldname}}

Submitted field value — the field's display value (a choice field renders its option label, not the stored value)

{{email.fieldname.subkey}}

Sub-value of a compound field — booking datepicker: .date / .time, or .checkin / .checkout, or .start / .end / .count

{{email.price}}

Computed formula result at submit time, formatted per the Number format setting

{{email.price_raw}}

Same amount, unformatted (plain dot-decimal)

{{email.currency}}

Currency symbol (e.g. $) — put it before {{email.price}}

{{site.admin_email}}

WordPress admin email

{{site.name}}, {{site.url}}, {{site.tagline}}

Site context

{{date.now}}, {{date.date}}, {{date.time}}, {{date.year}}

Submission timestamp

Bare {{price}} does not resolve. Abstract_Submit_Action::flatten_context() only ever emits section-prefixed keys, and price / price_raw / currency are aliases it derives from the internal _price_formatted / _price / _currency entries — inside the email section only. Always write {{email.price}}.


Complex Form Patterns

Multi-output product data form

Use when the user selects 2+ choice fields and the form must display multiple data values from a matched row (specs, codes, parameters) in addition to computing a price. Classic example: glass film configurator where glass type + privacy level identify a product SKU row.

Architecture overview:

  1. One table transformation per data column — each 2D lookup uses the same rowVar/colVar but targets a different column. Name them after what they return: vlt, uv_rejection, warranty, max_width_cm, max_height_cm, film_code, application_msg, etc.

  2. Conditional logic (hide-option) to filter the dependent field's options based on the first field's value. Configure one rule per incompatible option group.

  3. Number fields with dynamic constraints — set "cfMax":"max_width_cm" and "cfMax":"max_height_cm" in the block HTML text-input attributes. Error messages auto-resolve the constraint value — no extra hint block needed.

  4. Infoblock displaying all looked-up values alongside the price (all referenced transformations must have "expose": true):

  5. Formula uses the looked-up roll width (not the user-entered width) multiplied by clamped height and a hardcoded price per m²:

    max_width_cm = transformation result (full roll width from data table), height = user input field, 45 = price per m² hardcoded directly in the formula.

Example transformations for a glass film table (glass_type × privacy_level → per-column values):


WP CLI Cheatsheet


Verify a CLI-authored form conforms

Run these after any WP CLI authoring — they catch every failure mode above without opening the editor.

6. Block validation — the one check only the editor can run. Checks 1–5 all pass on a field whose saved HTML doesn't match its block's JavaScript save() (e.g. a checkboxes-field with no wrapper <div>, or legacy option markup), because PHP can't run save(). Open the form in the editor (post.php?post=FORM_ID&action=edit), wait for it to load, and paste this into the browser console. It lists every invalid block with the HTML the editor expects next to what is saved, and whether the form opens "dirty" (an editor-side migration rewrote something):

Pass = invalid: [] and opensDirty: false. Fix an invalid block by rewriting it to the expected markup (keeping its block-comment attributes), using wp_slash() as below.

CRITICAL — wp_update_post() eats backslashes. Always wrap post_content in wp_slash(). wp_insert_post() / wp_update_post() expect slashed data and run wp_unslash() on everything you hand them. Block-comment JSON is full of backslashes — Gutenberg escapes & as \u0026, < as \u003c, > as \u003e, " as \u0022 and \ as \u005c — so passing raw content silently turns every \u0026 into a bare u0026. The label then reads Pick a date u0026 time in the form editor, in any page embedding it, and on the front end, with no error anywhere. It also propagates: the synced pattern re-syncs from the broken content and _craftforms_prerendered_html bakes it in.

This affects wp eval / plugin code only. WP-CLI's own wp post create|update <file> slashes for you and is safe.

To repair a form already corrupted this way:

Then re-check with the parse_blocks probe — the decoded attribute should show a real &:

Re-save the form afterwards so the synced pattern re-syncs, and regenerate the prerender (snippet below).

Beware when probing from the shell: wp db query runs the MySQL client in batch mode, which doubles backslashes in its own output — a correct \u0026 prints as \\u0026 there. Trust parse_blocks(), not the raw dump.

Rendering the form post directly from CLI leaves data-action empty because get_the_ID() is null — set up the post first if you regenerate _craftforms_prerendered_html by hand:

Starters Checklist

When creating a new starter:

If using conditional logic:

If using dynamic validation (min/max/etc. driven by a transformation):

If using option annotations:

For WC-ready starters:

Last updated

Was this helpful?