Go Back to Parent Doc

Developer Reference

Forming Forms is built to be extended. Forming Pro itself uses nothing but the public hooks on this page. Text domain: forming-form-builder. PHP namespace: Forming\. Function and hook prefix: forming_. Block namespace: forming/.

Architecture in one paragraph

A form is the parent block forming/form using InnerBlocks, restricted to an allowlist of field blocks. Every block is dynamic. Each has a PHP render callback, so the front-end markup is generated on the server. On submit, Forming\Submissions\Pipeline rebuilds the field schema from the form’s signed source with parse_blocks() and validates against that, never against the browser’s field list. Submissions are stored in the forming_entry post type, and a {prefix}forming_forms table indexes every form for the admin screens. There is no form CPT and no shortcode system. Reuse is core synced patterns.


How a submission is processed

POST /wp-json/forming/v1/submit hands every request to Forming\Submissions\Pipeline, which runs these steps in order. A step that fails stops the pipeline.

#StepWhat happensHooks
1ReceiveVerify the REST nonce.
2SchemaRebuild the form from its signed source with parse_blocks(). The browser’s field list is never trusted.forming_form_settings, forming_field_definition
3ProtectHoneypot and signed time-trap, then forming_spam_check: rate limit (priority 5), CAPTCHA (10), Akismet (20) and add-on checks. Spam gets a fake success response and is never stored or emailed.forming_min_submit_seconds, forming_spam_check, forming_rate_limit, forming_captcha_providers, forming_akismet_data
4ValidateValidate and sanitize each field by type, then let add-ons veto or change the result.forming_rest_submit_request, forming_validate_field, forming_before_submission, forming_submission_data
5StoreSave a forming_entry post, if the form stores submissions.forming_entry_meta, forming_entry_created
6Hand offTell add-ons about the submission.forming_submission_saved
7NotifySend the notification email.forming_notification_email
8RespondReturn the success message or the redirect.forming_success_response

The Submission object

Most submission hooks receive a Forming\Submissions\Submission:

Property / methodTypeDescription
$requestWP_REST_RequestThe raw request (JSON or multipart).
$form_idstringThe form block’s formId.
$post_idintThe post the form was viewed on.
$sourcestringSigned reference to the content the form block lives in (post:12, a template or template part, …).
$settingsarrayNormalized form settings (see forming_form_settings).
$schemaarray[]Field definitions rebuilt from the blocks.
$rawarrayRaw submitted values. Never trust these.
$fieldsarray[]Validated fields: name, label, type, value, display.
$entry_idintStored entry ID, or 0 when not stored (yet).
data()arrayValidated values as name => value.
field( $name )array|nullA single validated field.

Field types

Every field block maps to a field type. The type has a validator, and optionally a definition callback that copies extra block attributes into the field definition the validator receives.

forming_register_field_types (action) — since 1.1.0

Register your own field type. The registry boots the first time it is used, during init, so add this hook from your main plugin file or on plugins_loaded. A registered block is added to the form’s allowlist automatically, so you don’t need forming_field_blocks as well.

add_action( 'forming_register_field_types', function ( Forming\Fields\Registry $registry ) {
	$registry->register( 'rating', array(
		// Block that renders the field. Default: "forming/field-{type}".
		'block'               => 'my-plugin/field-rating',

		// Copy extra block attributes into the definition.
		'definition_callback' => function ( $definition, $attributes ) {
			$definition['max'] = isset( $attributes['max'] ) ? (int) $attributes['max'] : 5;
			return $definition;
		},

		// Return array( 'value' => …, 'display' => … ) or a WP_Error.
		'validate_callback'   => function ( $raw, $field ) {
			$stars = (int) $raw;
			if ( $stars < 1 || $stars > $field['max'] ) {
				return ! empty( $field['required'] ) || '' !== (string) $raw
					? new WP_Error( 'invalid', __( 'Please choose a rating.', 'my-plugin' ) )
					: array( 'value' => '', 'display' => '' );
			}
			return array( 'value' => $stars, 'display' => sprintf( '%d / %d', $stars, $field['max'] ) );
		},

		// Set to false for blocks that submit no value (buttons, page breaks).
		'is_data'             => true,

		// Since 1.2.0: true when the field owns the field blocks nested in it
		// (a repeater). Their definitions arrive in $field['fields'] instead of
		// the form schema, and this field's validator checks them.
		'inner_fields'        => false,
	) );
} );

The registry also provides unregister( $type ), get( $type ), get_by_block( $block_name ), all() and blocks(). Forming\Fields\Validators holds the built-in validators, along with the Validators::ok(), Validators::required_error( $field ) and Validators::invalid_error( $field, $default_message ) helpers. Pass the field definition so the author’s Message when empty and Message when the value is not valid settings (requiredMessage, invalidMessage) are used.

Your block’s render_callback must output an input named fields[{name}], or fields[{name}][] for multiple values. Derive the name with Forming\Submissions\Schema::field_name( $attributes ) so that rendering and validation always agree.

“Other (please specify)” choices (radio and checkbox groups, since 1.2.0): the choice submits Validators::OTHER (__other__) in fields[{name}], and the typed answer arrives separately in forming_other[{name}]. Fields with allowOther receive it as otherText in their definition, and the stored value is the typed text. Validators::multiple_choice() validates multi-select and checkbox groups, including that answer.

forming_field_blocks (filter) — since 1.0.0

The list of block names allowed inside a form. It already includes the blocks of registered field types. A block that is allowed here but not registered is validated as single-line text.

add_filter( 'forming_field_blocks', function ( $blocks ) {
	$blocks[] = 'my-plugin/field-notes';
	return $blocks;
} );

PHP filters

forming_form_settings — since 1.1.0

Filters a form’s normalized settings. Use it to read attributes your add-on added to the forming/form block.

add_filter( 'forming_form_settings', function ( $settings, $attrs ) {
	$settings['entryLimit'] = isset( $attrs['entryLimit'] ) ? absint( $attrs['entryLimit'] ) : 0;
	return $settings;
}, 10, 2 );
ParamType
$settingsarrayformId, recipients, subject, fromName, fromEmail, successAction, successMessage, redirectUrl, honeypot, timeTrap, captcha, storeSubmissions.
$attrsarrayRaw attributes of the form block.

forming_field_definition — since 1.1.0

Filters a field definition while it is rebuilt from its block, after the type’s definition_callback has run.

ParamType
$definitionarraytype, block, name, label, required, options, mode, requiredMessage, invalidMessage, plus type-specific keys: minLength, maxLength and pattern (text, textarea, phone), minDate and maxDate (date, today or YYYY-MM-DD), min, max and step (number).
$attributesarrayBlock attributes, with block.json defaults applied.
$block_namestringBlock name.

forming_spam_check — since 1.1.0

Return true for a clean submission or a WP_Error to reject it. A rejected submission gets a fake success response by default. To show your message to the visitor instead, add 'silent' => false to the error data, for example for a failed CAPTCHA:

add_filter( 'forming_spam_check', function ( $result, $submission ) {
	if ( is_wp_error( $result ) ) {
		return $result; // An earlier check already flagged it.
	}
	$token = (string) $submission->request->get_param( 'my_captcha_token' );
	if ( ! my_captcha_verify( $token ) ) {
		return new WP_Error( 'my_captcha', __( 'Please complete the CAPTCHA.', 'my-plugin' ), array( 'silent' => false ) );
	}
	return $result;
}, 10, 2 );

The built-in checks set these error codes: forming_spam_honeypot, forming_spam_time_signature and forming_spam_too_fast. The checks hooked into this filter add forming_rate_limited, forming_captcha_missing, forming_captcha_failed, forming_captcha_unreachable, forming_captcha_low_score (reCAPTCHA v3, silent) and forming_spam_akismet (silent).

forming_rate_limit — since 1.2.0

Filters the per-IP submission limit. Return limit (submissions, 0 for no limit) and window (seconds). Defaults to the Submissions allowed per visitor per hour setting over one hour.

add_filter( 'forming_rate_limit', function ( $limit, $submission ) {
	if ( 'newsletter' === $submission->form_id ) {
		$limit['limit'] = 3;
	}
	return $limit;
}, 10, 2 );

forming_client_ip — since 1.2.0

Filters the visitor IP used by the rate limit, CAPTCHA, Akismet and the stored entry IP. Behind a proxy or CDN, return the address from a header your own proxy sets, such as CF-Connecting-IP.

forming_captcha_providers — since 1.2.0

The CAPTCHA services offered under Settings → Spam protection, keyed by id. Add an instance of a Forming\Spam\Captcha\Provider subclass. It must implement id(), label(), script_url() and verify_url(). Its keys are stored as {id}_site_key and {id}_secret_key, and its settings fields are added for you. Override mode() (checkbox, invisible or score), extra_fields() or check_result() as needed. The front-end script supports the reCAPTCHA, hCaptcha and Turnstile JavaScript APIs; a provider with a different API needs its own script that puts the token into the forming_captcha_token field.

forming_akismet_data — since 1.2.0

Filters the parameters sent to Akismet’s comment-check. By default comment_author is the first text field whose name contains “name”, comment_author_email the first email field, comment_author_url the first URL field and comment_content the textarea values. Runs only when Akismet is active and connected and Check submissions with Akismet is on.

forming_dynamic_value — since 1.2.0

Filters a Hidden field’s server-side dynamic value at submission: post_title, post_id, post_url (the page the form was viewed on, if public), user_email, user_name, user_id (the logged-in visitor) and date. These ignore whatever the browser sent. An empty result falls back to the field’s default value. query (a URL parameter) and referrer are filled in by the browser and can be changed by the visitor, so don’t trust them.

ParamType
$valuestringResolved value.
$typestringDynamic value type.
$post_idintPost the form was viewed on.

forming_min_submit_seconds — since 1.0.0

The minimum number of seconds between rendering a form and submitting it (default 2).

forming_rest_submit_request — since 1.1.0

Filters the raw fields[…] values before validation. Use it to merge in values from elsewhere in the request, such as uploaded files ($request->get_file_params()). Whatever you add still goes through the field’s validator.

ParamType
$rawarrayRaw values keyed by field name.
$requestWP_REST_RequestThe request.
$submissionSubmission

forming_validate_field — since 1.1.0

Filters a single field’s validation result. Return a WP_Error to reject the value (its message is shown under the field), or an array with value and display to accept it.

ParamType
$resultarray|WP_ErrorResult from the type’s validator.
$fieldarrayField definition.
$rawmixedRaw submitted value.

forming_before_submission — since 1.1.0

Runs after every field is valid and before anything is stored or sent. Return a WP_Error to stop the submission. Use it for entry limits, schedules, payments and similar checks. The error message is shown above the form. To flag fields, add errors (name => message) to the error data. The response status defaults to 400; set status in the error data to change it.

add_filter( 'forming_before_submission', function ( $result, $submission ) {
	$limit = isset( $submission->settings['entryLimit'] ) ? $submission->settings['entryLimit'] : 0;
	if ( $limit && my_count_entries( $submission->form_id ) >= $limit ) {
		return new WP_Error( 'my_limit', __( 'This form is no longer accepting entries.', 'my-plugin' ) );
	}
	return $result;
}, 10, 2 );

forming_submission_data — since 1.1.0

Filters the validated fields (name, label, type, value, display) before they are stored and emailed.

forming_entry_display_value — since 1.2.0

Filters how a stored entry value is shown in the Entries screen, CSV export, dashboard widget and personal data export, with ( string $display, array $field ). $field has name, label, type, value and, for choice fields, options. Use it for custom field types, for example to show a toggle’s 1 as “Yes”.

forming_entry_meta — since 1.1.0

Filters the meta saved with a new entry, as key => value. Prefix your own keys with your plugin’s prefix.

ParamType
$metaarraySee Entry meta keys.
$entry_idint
$submissionSubmission|null

forming_notification_email — since 1.0.0

Filters the notification email before wp_mail(): array( 'recipients', 'subject', 'body', 'headers' ), plus $settings, $fields and $context.

forming_success_response — since 1.1.0

Filters the JSON sent to the browser after a successful submission. The payload has success, action (message or redirect), and message (HTML) or redirect (URL). It is not applied to the fake response sent for spam.

add_filter( 'forming_success_response', function ( $payload, $submission ) {
	if ( $submission->entry_id ) {
		$payload['message'] .= sprintf( ' Your reference is #%d.', $submission->entry_id );
	}
	return $payload;
}, 10, 2 );

PHP actions

forming_init — since 1.2.0

Fires once Forming has loaded, on init at priority 0, with the Forming\Plugin instance. Boot add-ons here: every Forming class is loaded and FORMING_VERSION is defined.

forming_admin_enqueue_scripts — since 1.2.0

Fires on Forming admin screens just before the admin app is enqueued, with ( string $page, string $hook_suffix ). Enqueue add-on scripts here, in the footer, so they print before the app and their forming.admin.* filters are in place when it renders.

forming_entry_created — since 1.1.0

Fires after an entry has been stored, with ( int $entry_id, Submission|null $submission ).

forming_submission_saved — since 1.0.0

Fires for every valid submission, whether or not it was stored. Spam never reaches it.

ParamType
$entry_idintEntry ID, or 0 when the form doesn’t store submissions.
$dataarrayname => value.
$form_idstring
$submissionSubmissionSince 1.1.0. Hook with 10, 4 to receive it.

Entry meta keys

Entries are forming_entry posts. These meta keys are stable:

KeyValue
_forming_form_idForm identifier.
_forming_source_idID of the post the form was submitted from.
_forming_sourceSigned reference to the content the form lives in. Since 1.1.0.
_forming_user_idID of the logged-in submitter; absent for visitors. Since 1.1.0.
_forming_fieldsSnapshot of the fields at submission time: a list of name, label, type and, for choice fields, options (label, value). Since 1.1.0.
_forming_readSet once the entry has been opened or marked as read; absent while unread. Since 1.1.0.
_forming_starredSet on starred entries. Since 1.1.0.
_forming_ip, _forming_user_agentSubmitter IP address and browser, only when enabled under Settings → Data & privacy. Since 1.1.0.
forming_field_{name}One value per field (an array for multiple choices).

Trashed entries use the trash post status and are restored as publish.


Editor (JS) filters

These use @wordpress/hooks (the wp-hooks script).

forming.form.inspectorPanels — since 1.1.0

Adds panels to the Form block’s sidebar, below the built-in ones.

import { addFilter } from '@wordpress/hooks';
import { PanelBody, TextControl } from '@wordpress/components';

addFilter( 'forming.form.inspectorPanels', 'my-plugin/limits', ( panels, { attributes, setAttributes } ) => (
	<>
		{ panels }
		<PanelBody title="Entry limit" initialOpen={ false }>
			<TextControl
				type="number"
				label="Maximum entries"
				value={ attributes.entryLimit || '' }
				onChange={ ( value ) => setAttributes( { entryLimit: Number( value ) } ) }
			/>
		</PanelBody>
	</>
) );

Props: { attributes, setAttributes, clientId }. Always render the incoming panels value so other add-ons’ panels still show.

forming.field.inspectorControls — since 1.1.0

Adds controls at the end of the shared “Field Settings” panel of every field block that uses it. Props: { name, clientId, attributes, setAttributes }, where name is the block name. Check name to target specific fields, and always render the incoming value.


Front-end (JS) events

Events are dispatched on the <form> element and bubble, so you can listen on document.

EventCancelabledetailUse
forming:before-submityes{ formData }Add or change values in formData (for example a CAPTCHA token). Call preventDefault() to stop the send.
forming:successyes{ payload }React to success. Call preventDefault() to skip the built-in message/redirect handling.
forming:errorno{ payload, status }React to errors. status is 0 for a network error.
document.addEventListener( 'forming:before-submit', ( event ) => {
	event.detail.formData.set( 'my_captcha_token', window.myCaptcha.getToken() );
} );

Client-side validation (since 1.2.0) runs before forming:before-submit, using the rendered HTML attributes (required, minlength, maxlength, pattern, min, max, step, input type) and the same messages as the server. The form’s data-forming-messages attribute carries the translated messages, and data-required-message / data-invalid-message on a control carry its custom ones. Invalid controls get aria-invalid="true", and the error is linked through aria-describedby. Focus moves to the first invalid control, and errors update as the visitor types. The server still validates every submission. Add data-forming-server-only to a field wrapper to skip client-side checks for it and every field inside it (for example a repeater whose inner fields share its name); its server error then shows on that wrapper.


Admin screens

forming_capability (filter) — since 1.1.0

The capability needed for a Forming admin screen and its REST endpoints. The default is manage_options. $context is one of dashboard, forms, entries, settings or tools.

add_filter( 'forming_capability', function ( $capability, $context ) {
	return 'forms' === $context ? 'edit_pages' : $capability;
}, 10, 2 );

forming_admin_menu_items (filter) — since 1.1.0

The Forming submenu pages, as a list of slug, title, menu_title and capability. A page you add gets a submenu item and the admin app shell (header and tabs). Render its content with the JS filter below.

forming.admin.pages (JS filter) — since 1.1.0

Maps page slugs to React components. The current slug is in window.formingAdmin.page.

addFilter( 'forming.admin.pages', 'my-plugin/license', ( pages ) => ( {
	...pages,
	license: LicensePage,
} ) );

Entries endpoints

All of these require the entries capability (see forming_capability).

RouteDescription
GET admin/entriesList. Accepts search (field values), form_id, status (all, unread, read, starred, trash), order, page and per_page. Returns X-WP-Total, X-WP-TotalPages and X-Forming-Unread headers.
GET admin/entries/{id}One entry with its fields, user, and the newer/older entry of the same form (prev_id, next_id).
POST admin/entries/{id}Set read and/or starred.
POST admin/entries/batchids plus action: read, unread, star, unstar, trash, restore or delete (trashed entries only).
GET admin/entries/formsForms that have entries, for the form filter.

CSV export is admin-post.php?action=forming_export_entries&form_id=… (optionally from and to as Y-m-d), with a forming_export_entries nonce.

Settings

Global settings live in the forming_settings option and are read with Forming\Settings\Settings::get( $id, $fallback ). The Settings screen is generated from a schema, so a new tab or field needs no JS.

forming_settings_tabs (filter) — since 1.1.0

The tabs, each with id and title. Add 'save' => false (since 1.2.0) for a tab that has no settings to save, such as one whose UI comes entirely from forming.admin.settingsTab; its Save button is hidden.

forming_settings_fields (filter) — since 1.1.0

The fields. Each has id, tab, type (text, email, password, textarea, toggle, number or select), label and default, plus optional help, options (for select), min, max and show_if. show_if maps setting ids to the values that show the field, for example array( 'captcha_provider' => array( 'turnstile' ) ). Values are sanitized by type when saved.

add_filter( 'forming_settings_tabs', function ( $tabs ) {
	$tabs[] = array( 'id' => 'license', 'title' => __( 'License', 'my-plugin' ) );
	return $tabs;
} );

add_filter( 'forming_settings_fields', function ( $fields ) {
	$fields[] = array(
		'id'      => 'my_license_key',
		'tab'     => 'license',
		'type'    => 'text',
		'label'   => __( 'License key', 'my-plugin' ),
		'default' => '',
	);
	return $fields;
} );

forming.admin.settingsTab (JS filter) — since 1.1.0

Renders extra UI at the end of a settings tab. Arguments: ( element, tabId, values, update ), where update( id, value ) changes a value.

Dashboard

forming_dashboard_data (filter) — since 1.1.0

Filters the Dashboard data (cards, series, latest, top_forms, checklist, …). Add your own keys and render them with forming.admin.dashboardWidgets.

forming_setup_checklist (filter) — since 1.1.0

The setup checklist items, each with id, label, done, help, and an optional action (label and url).

forming.admin.dashboardWidgets (JS filter) — since 1.1.0

Renders extra widgets below the Dashboard lists. Arguments: ( element, data ).

Log

Forming\Support\Log::add( $type, $message, $context = array(), $level = 'error' ) writes to the rolling log shown under Forming → Tools → Logs (the last 100 entries are kept). Forming logs failed notification and test emails there.

Privacy

Forming registers a personal data exporter and eraser (Tools → Export/Erase Personal Data) that match entries by any field equal to the email address, and suggests text for the privacy policy. Optional IP/browser storage adds the _forming_ip and _forming_user_agent entry meta. Entry retention runs daily on the forming_delete_old_entries cron hook.

More admin endpoints

RouteDescription
GET admin/dashboardDashboard data. days (7, 30, 90) and form_id.
POST admin/dashboard/checklistdismissed: hide or show the setup checklist for the current user.
GET / POST admin/settingsSchema and values / save values. Returns field errors on 400.
POST admin/settings/test-emailSend a test email to to.
GET admin/tools/statusSystem status groups.
POST admin/tools/reindexRebuild the forms index now.
GET admin/tools/logs, POST admin/tools/logs/clearRead / clear the log.
GET admin/tools/settings/export, POST admin/tools/settings/importExport settings as JSON / import a data object from an export.

GET /wp-json/forming/v1/admin/forms

Lists indexed forms. It accepts search, status, orderby (updated_at, entry_count, last_entry_at, title), order, page and per_page, and returns X-WP-Total and X-WP-TotalPages headers.


Adding a form-level setting

A complete add-on setting touches four places:

  1. Attribute (PHP): add it to forming/form with the core register_block_type_args filter.
  2. Attribute (JS): add it with the blocks.registerBlockType filter so the editor saves it.
  3. UI: render a control through forming.form.inspectorPanels.
  4. Server: read it through forming_form_settings, then act on it in forming_before_submission, forming_submission_saved and so on.
add_filter( 'register_block_type_args', function ( $args, $name ) {
	if ( 'forming/form' === $name ) {
		$args['attributes']['entryLimit'] = array( 'type' => 'number', 'default' => 0 );
	}
	return $args;
}, 10, 2 );
addFilter( 'blocks.registerBlockType', 'my-plugin/limit-attr', ( settings, name ) =>
	'forming/form' !== name
		? settings
		: { ...settings, attributes: { ...settings.attributes, entryLimit: { type: 'number', default: 0 } } }
);

The submit endpoint

POST /wp-json/forming/v1/submit accepts JSON or multipart form data.

ParameterNotes
forming_form_idThe Form block’s formId (required).
forming_post_idID of the post the form was viewed on (required).
forming_source, forming_source_sigSigned reference to the content the form lives in.
forming_nonceA wp_rest nonce, also accepted in the X-WP-Nonce header. The bundled script fetches a fresh one when the page may be cached.
forming_time, forming_time_sigSigned render timestamp for the time-trap.
forming_hpThe honeypot. Must be empty.
forming_captcha_tokenCAPTCHA response token, when the form uses CAPTCHA.
fields[...]Submitted values keyed by field name (fields[name][] for multiple values).
forming_other[...]Typed “Other (please specify)” answers.

The route is public and protected by the nonce, signatures and spam checks, so logged-out visitors can submit. Responses:

  • 200: { success: true, action: "message", message: "…" } or { success: true, action: "redirect", redirect: "…" }
  • 403 forming_invalid_nonce: expired or missing nonce
  • 404 forming_form_not_found: no form with that ID at that source
  • 422 forming_validation_failed: with data.errors as a field name → message map
  • 429 or 400: rate limit, CAPTCHA or forming_before_submission errors with a message to show

A submission caught by a silent spam check returns the same 200 payload as a valid one, without storing or emailing anything.

WP-CLI

wp forming forms list      # List indexed forms (supports --format)
wp forming forms reindex   # Rebuild the forms index

Useful classes

ClassRole
Forming\Fields\RegistryField types: register(), get(), get_by_block(), all()
Forming\Fields\ValidatorsBuilt-in validators and the ok(), required_error() and invalid_error() helpers
Forming\Blocks\RendererShared field markup, and allowed_html() for wp_kses()
Forming\Submissions\SchemaSchema rebuilding, validation and field_name()
Forming\Submissions\PipelineThe submission steps
Forming\Submissions\SubmissionThe object passed to submission hooks
Forming\Submissions\EntriesThe forming_entry post type and entry creation
Forming\Settings\SettingsGlobal settings: Settings::get( $id, $fallback )
Forming\Support\LogThe log shown under Tools → Logs

Building from source

npm install && composer install
npm run start   # watch
npm run build   # production build into assets/ and a fresh .pot

Block sources live in src/blocks/, the admin app in src/admin/, and compiled output in assets/. PHP follows the WordPress Coding Standards, and the ruleset is in phpcs.xml.

Shopping Cart
  • Your cart is empty.