Hooks & extension points
LocalForm is built to be extended - the Pro add-on uses only the public hooks below, and third-party code can do the same.
Lifecycle
| Hook | Type | Purpose |
|---|---|---|
localform_loaded | action | Fires once all LocalForm classes are initialized. Register add-on functionality here. |
localform_pro_fs_loaded | action | Pro. Fires once the Freemius SDK has been initialized, per Freemius' integration guide. Always fires when Pro is active, including when the SDK was deliberately not booted - call localform_pro_fs() and check for null before using the instance. |
localform_submission_created | action | Fires after a submission is accepted. Args: $submission_id (null in webhook-only mode), $form, $data. |
Forms
| Hook | Type | Purpose |
|---|---|---|
localform_form_data | filter | Modify the form data assembled from the builder request before Form::save() - including $data['fields'], so an add-on can inject, resync or remove a question it manages. Args: $data, $post (sanitized request), $form_id (0 when creating). Form::sanitize_fields() still runs afterwards. |
localform_preview_form_data | filter | The same data on its way to the builder's preview instead of to storage. Same arguments, same shape - but nothing is saved, so a callback that refuses a save (a naming rule, a missing required question) must not hook this one: it would be refusing to show somebody the form they are still building. A callback that shapes the form should hook both, or the preview shows a form the save would not have produced. |
localform_form_saved | action | Fires after a builder save succeeded, with the real $form_id (also for a new form) and the sanitized $post. Where an add-on persists its own per-form settings. |
localform_form_duplicated | action | Fires after a form was copied. Args: $new_id, $source_id. Copy per-form settings kept outside the forms table here; the copy's fields keep their IDs. |
localform_form_deleted | action | Fires after a form and its submissions were deleted. Arg: $form_id. Clean up per-form settings here. |
localform_before_form | action | Fires inside a form's wrapper, above its title, on the path where the form is actually being drawn for filling in - a form nobody may submit right now has already returned with its closed message. Arg: $form. Echo escaped markup; nothing here is filtered. Where an add-on says something about this form to the person about to fill it in. |
localform_form_imported | action | Fires after a form was created by an import. Args: $form_id, $definition (the source's output, after field names were assigned). Carry over anything you keep outside the forms table here. |
Emails
LocalForm\Email::registrant_address( $form, $data ) answers where a
confirmation about a response would go - the form's configured confirmation
field, or the first email question carrying a valid address. Use it rather
than resolving an address again: anything else a site sends a registrant has
to reach the same person the confirmation did.
| Hook | Type | Purpose |
|---|---|---|
localform_pro_waitlisted | action | Pro. Fires after a registration was put on a form's waiting list. Args: $submission_id, $form, $position (1 being next in line). |
localform_pro_waitlist_admitted_email | filter | Pro. The email telling somebody they have been given a place. Args: $mail (to, subject, body), $form, $entry. Return an empty array, or unset to, to suppress it - for a site that would rather ring them. |
Importing
| Hook | Type | Purpose |
|---|---|---|
localform_importers | filter | Add an import source to Forms → Import. Args: $sources (array of LocalForm\Importer_Source). Append an instance of your own subclass; anything that is not an Importer_Source is ignored, and a slug that is already taken is replaced. |
An import source is a subclass of LocalForm\Importer_Source. It answers four
things - slug(), label(), description() and to_definition() - and never
writes anything itself: it hands back a definition and Importer saves it
through Form::save(), so an imported form goes through exactly the same
validation as one built by hand.
add_filter( 'localform_importers', function ( array $sources ): array {
$sources[] = new My_Importer();
return $sources;
} );
class My_Importer extends LocalForm\Importer_Source {
public function slug(): string {
return 'my-plugin';
}
public function label(): string {
return 'My Plugin';
}
public function description(): string {
return 'Reads the forms My Plugin has on this site.';
}
// 'list' (the default) enumerates forms already on this site; 'paste'
// takes whatever the user pastes in and enumerates nothing.
public function available_forms(): array {
return LocalForm\Importer::posts_of_type( 'my_plugin_form' );
}
public function to_definition( string $reference ): array {
return [
'title' => 'Contact us',
'description' => '',
'fields' => [
// Partial field definitions, as in Templates: type, label,
// required, options. A `name` is kept if you supply one,
// and derived from the label if you do not.
[ 'label' => 'Your email', 'type' => 'email', 'required' => true ],
],
// Anything you could not bring across, in the reader's language.
// `skipped()` and `converted()` on the base class word these for
// you consistently with the built-in sources.
'notes' => [],
];
}
}
Throw an Exception from to_definition() when the reference cannot be read;
its message is shown to the user on the Import screen.
Removed in 1.4.0
| Hook | Replacement |
|---|---|
localform_admin_settings_event | Gone. Event details are a core feature now; the settings card is rendered by Admin::render_settings_section_event(). |
localform_save_event_data | Gone. Admin::save_form() reads the event fields from the request directly. |
localform_is_pro / localform_is_pro_active() | Gone. Core no longer branches on whether an add-on is present, because it no longer has anything to gate. An add-on that needs its own presence flag should track it itself. |
Removed in 1.8.0
| Hook | Replacement |
|---|---|
localform_event_card_details / localform_event_card_after_price / localform_event_card_has_details | localform_event_meta_items. The event card is gone: its facts now sit in one inline band inside the form card, so a contribution is a keyed text fragment rather than a label/value row of its own. The band renders whenever the filtered array isn't empty, which is what the old has_details filter existed to force. |
localform_admin_settings_after_event is not a revival of the removed pair: the
Event Details card, its fields and their save path are core's own and always
render. The action only lets an add-on append controls of its own below them.
Abilities (MCP / AI)
LocalForm\Abilities registers localform/list-forms, localform/get-form and
localform/save-form on wp_abilities_api_init (WordPress 6.9+), under a
localform ability category of its own. They read and write a form in the
backup entry shape, so an add-on that already answers localform_backup_form
is carried by get-form for free; these two hooks are what it needs for the
other direction.
| Hook | Type | Purpose |
|---|---|---|
localform_ability_form_schema | filter | The JSON Schema describing a form entry, used as save-form's input schema and get-form's output schema. An add-on storing per-form settings outside the forms table should describe its key here, or a caller has no way to know it exists. Arg: $schema. Never introduce an empty properties map - PHP encodes [] as a JSON list, and Gemini rejects the entire tool list over it. A branch that takes no input should declare no schema at all. |
localform_ability_form_input | filter | One form entry on its way into Backup::import() from save-form. The import path does not fire localform_form_data, so an add-on that builds a field of its own in the builder gets its chance here instead - Pro's event-date question is built this way. Args: $input, $existing_id (0 when creating). |
localform_pro_ai_system_instruction | filter | Pro. The instruction the in-admin assistant runs with, as a list of paragraphs. Args: $lines, $form_id (0 outside the builder). |
localform_pro_ai_model | filter | Pro. Which provider and model the assistant asks for, as [ 'provider' => 'google', 'model' => 'gemini-2.5-pro' ]. Either may be '' to leave that half to the AI client, which is the default. Set here it wins over Settings → AI Assistant, and that screen says so rather than pretending otherwise. The provider is binding - a turn it cannot answer fails and says why - while the model is a preference, checked against the models that provider offers and that can call tools; one that has been retired or mistyped is passed over and the client picks. Arg: $choice. |
localform_pro_ai_builder_data | filter | Pro. The state handed back to an open builder after the assistant has written to the form it is editing, keyed by the JavaScript global an add-on localized its builder script with, then by the keys that have gone stale ($data['my_addon']['configs'] = [...]). The browser replaces the contents of each object in place, so a script that captured it at page load sees the new values. An add-on that draws part of a field card from data passed at page load has to answer here, or its panels keep describing the form as it was when the screen opened. Args: $data, $form. |
Fields
| Hook | Type | Purpose |
|---|---|---|
localform_field_types | filter | Add custom field type slugs to the builder. |
localform_field_type_labels | filter | Labels for custom types, keyed by slug. |
localform_static_field_types | filter | Mark types as static (rendered, never validated or stored). |
localform_sanitize_field | filter | Persist extra per-field config before saving. Unknown keys posted by the builder are passed through, already reduced to plain text by Security::sanitize_request() - except a key named in Form::RICH_TEXT_KEYS (description, content, success_message, closed_message), which keeps a wp_kses_post() subset instead. Still apply the type-specific rule your config needs (absint, an allow-list, esc_url_raw, …) to what you keep. |
localform_render_field | filter | Return HTML to fully replace a field's frontend markup (return null to keep core rendering). Output is passed through wp_kses() with Security::allowed_field_html() before echoing, so stick to form-control markup (input, select, option, textarea, label, span, div, plus table/thead/tbody/tr/th/td for a matrix question) - other tags/attributes are stripped. Args: $html, $field, $form (since 2.7.0; null when the caller did not say which form is being rendered). |
localform_field_choices | filter | The choices a dropdown, checkbox or radio field offers, as ['value' => string, 'label' => string, 'disabled' => bool] per choice. Relabel, reorder, drop or disable them without taking over the field's markup - Pro's per-date event capacity uses it to grey out a date with no places left. A callback can only narrow what is on offer: a returned value that is not one of the field's own options is dropped, because validation would refuse it the moment somebody picked it. Disabling is a hint to the browser only - check the answer again on localform_validate_field. Args: $choices, $field, $form. Since 2.7.0. |
localform_validate_field | filter | Take over sanitization/validation of a submitted value for custom types. |
localform_likert_presets | filter | The ready-made scales a Likert question can be asked on, keyed by slug and shaped ['label' => string, 'scales' => [<points> => [<label>, ...]]]. Each width is written out separately, because a four-point agreement scale is not a five-point one with the middle removed. Scales are resolved on every read, so removing a preset later leaves questions using it falling back to the default scale rather than losing the answers already collected. Arg: $presets. Since 2.9.0. |
localform_field_is_applicable | filter | Return false to skip a field for this submission (not validated even when required, stored empty). Pro's conditional fields use this. |
localform_pro_field_name_rules | filter | Pro. The field name rules every form is checked against, so a site can declare its naming contract in code rather than in the admin screen. Each rule is ['name','label','types','keywords','aliases','severity','require_present']; severity is warn (say so in the builder), block (refuse the save) or auto (leave the form alone and rename the field in the outgoing payload). Keywords and aliases accept a list or a comma-separated string. Anything returned is sanitized exactly as a stored rule is - a rule without a name is dropped. Arg: $rules. |
localform_pro_field_name_problems | action | Pro. Fires when a save breaks one or more field name rules, whether or not the save was allowed through - the hook to log or alert on drifting field names. Args: $problems (each `['kind' => 'rename' |
localform_pro_field_blocks | filter | Pro. Add or replace the field blocks offered by the builder's "+" menu. Each block is ['key','label','description','fields']; each field is ['label','name','type','required','placeholder','options']. Runs on the blocks as the Field Templates screen left them - shipped blocks with any saved edits applied, deleted ones already gone, blocks made there included - so the filter has the last word over both. Unsupported types are coerced to text and keys are run through sanitize_key() after the filter. |
Quiz
Quiz mode lives in LocalForm\Quiz and hangs off the hooks above rather than
beside them - the score is a column of the responses table, a column of every
export, an entry in the webhook payload and a paragraph of the confirmation
email, and it reaches all four through the same extension points an add-on
would use. Two hooks of its own:
| Hook | Type | Purpose |
|---|---|---|
localform_quiz_result | filter | A graded submission, before anything is shown, exported or sent. The place for partial credit, a weighted section, or a pass decided on something other than the percentage. Keep the shape - the responses table, the exports, the emails and the webhook payload all read the same array: ['graded','correct','earned','total','percent','pass_mark','passed','questions'], each question ['name','label','type','points','earned','is_correct','answered','given','expected','feedback']. Args: $result, $form, $data. A conditional field that was hidden for this submission is stored empty and therefore marked wrong; an add-on that hides questions (Pro's conditional fields) should drop them from questions and from total here. |
localform_quiz_gradable_types | filter | Which question types can carry an answer key. Core marks radio, dropdown, checkbox, text and number. A type added here must produce a submitted value Quiz::is_correct() can compare against a list of strings - a single string, or an array of them - or it always marks wrong. Arg: $types. |
Per-field config, persisted through localform_sanitize_field like any other:
quiz_answers (the resolved list of accepted answers), quiz_points and
quiz_feedback. The builder posts a choice question's key as quiz_correct
(option indices, so an option can be renamed in the same save that marks it)
and a free-text one as quiz_answers_text; Quiz::sanitize_field() resolves
either into quiz_answers before storage, so nothing downstream has to know
which writer it came from.
Scores are computed on read and never stored, so there is no per-submission quiz record to hook, migrate or clean up - and correcting an answer key re-marks every response already collected.
Submissions & delivery
| Hook | Type | Purpose |
|---|---|---|
localform_raw_submission_data | filter | Modify the submitted field data before validation. Values arrive already unslashed and reduced to plain text by Security::sanitize_request(). Needed by field types whose value doesn't travel through $_POST (e.g. Pro's file upload, which reads $_FILES) - mark the field non-empty here or it will always look blank to the required/empty check. |
localform_is_accepting | filter | Final say on whether a form accepts submissions (`['open' => bool, 'reason' => string |
localform_webhook_payload | filter | Modify the webhook JSON payload before delivery. Args: $payload, $form, $submission_id (null in webhook-only mode). A marked quiz adds a quiz object (earned, total, percent, correct, graded, pass_mark, passed), so an automation can route a failed attempt onward. Fired for the submission delivery and, when Pro's payments send a second delivery on completion, for that one too - tell them apart by $payload['event'], which is absent on the first and payment_completed on the second. |
localform_submission_response | filter | What the browser is told about an accepted submission. The submission has already been saved and queued by this point, so this is about what happens next: setting redirect_url redirects the browser immediately and wins over the form's own redirect URL, and message is the success message (same HTML the form's own success message may carry). Args: $response_data, $form, $submission_id, $data. This is how Pro sends a registrant to a payment checkout. |
localform_pro_field_names_renamed | action | Pro. Fires when a field name rule set to auto rewrote one or more keys in an outgoing payload. The form itself is untouched - only the payload's keys change - so this is how a site sees that a mapping is in play. Args: $applied (stored name => sent name), $form. A mapping whose target key is already taken is skipped rather than overwriting it, and does not appear here. |
localform_pro_webhook_required_fields_missing | action | Pro. Fires while assembling a payload in which one or more global webhook fields marked required resolved to no value - neither a global default nor a per-form override. Args: $missing (the keys), $form. The payload is still delivered, carrying null for those keys: dropping a submission over a misconfigured field would lose data the visitor already entered. The builder blocks a save that would create this state, so it only shows up for forms saved before the field was made required. |
localform_admin_notification_recipients | filter | Change/suppress the admin notification recipients. Args: $to, $form, $data. $to is a string when the form names one address - which is what it always was, and what most forms still are - and an array once it names several, so a callback that concatenates or compares it must handle both. Return an empty value to suppress the notification. The form's own list is capped at Form::MAX_NOTIFICATION_RECIPIENTS (10); this filter is not, since what it returns has not come from a text field. |
localform_confirmation_email | filter | Modify the confirmation email (['to','subject','body']); empty to suppresses it. |
localform_email_field_value | filter | Say how one submitted value reads in a notification email. Args: $display (null by default), $value, $field (null if the form no longer has it), $form; return a string to replace the stored value. Needed by a field type whose stored value is an internal handle rather than something a person wrote - Pro's file upload returns the original filename here, because printing its storage token would put the one secret guarding that file into an email. |
localform_submission_deleted | action | Fires after a single submission row was deleted, by hand or by the retention purge. Args: $submission_id, $submission (the row as it was, data already decoded - the last copy of it). This is how an add-on takes data it stored outside the table with the row: Pro deletes the uploaded file the row referenced. Deleting a form does not fire this per row, because Form::delete() drops its submissions in one query - clean up per-form storage on localform_form_deleted instead. |
Spam & security
Every check below runs in LocalForm\Security, LocalForm\Spam_Filter and
LocalForm\Captcha, in that order, and each rejection is recorded in
LocalForm\Spam_Log with a reason. The site-wide settings are per-form
overridable: Security::settings( $form_id ) is the one place that resolves
them, and it is what every check reads.
| Hook | Type | Purpose |
|---|---|---|
localform_security_settings | filter | The security settings in force for a form, after any per-form overrides have been merged over the site-wide values. The final say on every spam setting - honeypot, browser check, minimum fill time, rate limit, duplicates, content filter, challenge mode. Args: $settings, $form_id (0 site-wide). |
localform_spam_filter_result | filter | The outcome of the content checks. Return ['reason' => string, 'detail' => string] to reject a submission core accepted, or null to clear one core flagged. reason is what shows in the blocked-attempts log - use one of Spam_Log::REASONS or it is recorded as a generic block. Args: $result, $data (sanitized, validated), $form_id. This is the hook for a site's own rule, or for an add-on wiring in an external spam service. |
localform_min_fill_seconds | filter | How quickly after loading a form a submission is treated as automated. Since 2.7.0 the value passed in is the resolved setting (site or per-form), and the form id comes with it. 0 disables the timing check alone. Args: $seconds, $form_id. |
localform_token_max_age | filter | How long an issued submission token stays valid. Default 12 hours; the frontend refreshes and retries once when one lapses. Arg: $seconds. |
localform_spam_block_threshold | filter | How many automation-shaped rejections one client may collect, per form, before it is turned away outright. Default 5. Args: $threshold, $form_id. |
localform_field_max_length | filter | The longest answer a field accepts, in characters. Defaults are 5000 for textarea, 254 for email, 25 for phone and 1000 for everything else; return 0 to accept any length. A field carrying its own Maximum length from the builder never reaches this filter. Args: $length, $field. |
localform_max_request_bytes | filter | The largest submitted payload the plugin will look at, measured over the answers only. Default 512 KB, floor 1 KB. Checked before anything walks the payload, so an oversized request is rejected rather than sanitized. Arg: $bytes. |
localform_max_request_fields | filter | How many submitted values one request may contain. Default 500. Arg: $count. |
localform_max_request_depth | filter | How deeply a submitted payload may nest. Default 6; core's own deepest field is two levels. Arg: $depth. |
A form's own overrides live in its security column and are read with
Form::security_overrides( $form ), which returns an empty array for a form
that follows the site. They are stored whole or not at all - the builder's
switch is "use this form's own settings", so a form switched back keeps
nothing behind. The blocked word, email domain and IP lists stay site-wide.
Export & UI
| Hook | Type | Purpose |
|---|---|---|
localform_export_headers / localform_export_row | filter | Append matching columns to a submissions export. Applied by LocalForm\Export for every format, so one pair of callbacks covers XLSX, both CSV flavours and anything an add-on registers. Args: $headers/$row, plus $submission (row only) and $form. |
localform_export_field_columns | filter | The export columns one question contributes. Almost every type is one column (['key' => string, 'label' => string]); a question holding several answers at once - core's Likert grid - returns one per answer instead, so a spreadsheet gets one variable per column. Export::headers() and Export::row() both walk this, so the two cannot fall out of alignment. Args: $columns, $field. Since 2.9.0. |
localform_export_column_value | filter | One cell of a multi-column field. Return a string to fill it; return the incoming null and the whole field's localform_format_field_value output is used, which is what every single-column type does. Same escaping contract as localform_format_field_value. Args: $display, $value (the whole field's stored value), $column, $field, $submission. Since 2.9.0. |
localform_export_formats | filter | Add a format to the Export dropdown on the Responses screen, as slug => label. Args: $formats, $form (the form being exported, or null where none is in hand - a format needing something specific from a form should still offer itself when $form is null, or WP-CLI cannot validate it). A slug added here must also be served from localform_export_download and localform_export_string. |
localform_export_download | action | Write an export in a format of your own. Fires before the built-in writers, so a callback that produces output must exit; returning lets XLSX/CSV run as usual. Args: $format (the chosen slug), $form, $headers, $rows (a Generator of row arrays - walk it once, and only if you are serving this format), $filename (no extension), $args (the submission filters the export covers). |
localform_export_string | filter | The same export rendered to a string instead of streamed, for WP-CLI and for Pro's scheduled email. Return a string to serve your format; return the incoming null and the built-in writers run. An add-on registering a format should answer this and localform_export_download - otherwise its format works in the browser and silently falls back to XLSX everywhere else. Args: $contents, $format, $form, $headers, $rows, $args. |
localform_print_submission_after | action | Render extra content at the bottom of a printable single response, before its footer. Echoed as-is, so escape it. Args: $submission, $form. |
localform_format_field_value | filter | Reformat a submitted value for the submissions table cell / an export cell (e.g. Pro's file upload turns a stored token into a download link). Default is the value's escaped plain text; the table cell passes the return value through wp_kses_post() before echoing (so stick to post-content-safe HTML like <a>), an export cell strips any markup back to plain text automatically. Also used by the privacy exporter, so whatever this returns is what a data request hands the person who made it. |
localform_forms_page_actions | action | Render a control after the "Add New" button on the Forms list, for an add-on offering another way of starting a form. Arg: $forms (the listed forms, after tag filtering). |
localform_builder_actions | action | Render a control in the builder's top bar, before the preview, diagram and design buttons. Anything rendered here sits inside the builder's <form>, so use type="button". Arg: $form (null when creating). |
localform_settings_page | action | Render extra UI at the bottom of the settings page. |
localform_settings_sections | filter | Add or replace sections on the LocalForm Settings page (['label','icon','callback'] keyed by slug). |
localform_settings_sidebar | action | Render a card at the foot of the Settings page's section menu, below the last section link. For something belonging to the plugin rather than to a section - a pointer, a status line, a link out; anything that is a setting belongs in a section registered through localform_settings_sections. Echoed as-is, so escape it. Arg: $section (slug of the section on screen). |
localform_site_report | filter | Add a section to the site report on Settings - Support (and to the LocalForm panel in Tools - Site Health - Info). Sections are keyed by slug and shaped ['label' => string, 'fields' => [ slug => ['label' => string, 'value' => string] ]]. Two rules, and they are why the report is allowed to exist: report counts and settings, never identifiers - no titles, slugs, URLs, addresses or answers - and never anything that had to be fetched over the network to know, since the report must stay something the plugin can produce without making a request. The environment section is the only one dropped on the way to Site Health, because WordPress reports that itself. Arg: $sections. Since 2.9.0. |
localform_form_html | filter | A form's complete rendered HTML, on the one path every way of putting a form on a page goes through - the fullscreen page template, the theme template via the_content, the [localform] shortcode and the block. Return different markup to replace the form entirely for this request. Args: $html, $form. Use this rather than the_content for anything that has to show instead of a form: the fullscreen template calls Frontend::render_form() directly and never runs the_content, so a the_content filter works on a themed page and silently does nothing on the page most sites actually use. Pro's payment confirmation is rendered here. Whatever is returned is echoed as-is, so escape it. |
localform_submission_columns | filter | Extra columns in the responses table, as headers keyed by slug, appended after the form's questions and before Date. The submissions on screen come with it, so a column backed by a table of your own can load a whole screenful in one query. Args: $columns, $form, $submissions. |
localform_submission_column | filter | One cell of a column registered above. Same escaping contract as localform_format_field_value - the return value is echoed through wp_kses_post(). Args: $content, $column (slug), $submission, $form. |
localform_admin_settings_after_event_card | action | Render a settings card of your own in the builder's Settings tab, after the Event Details card. Arg: $form (null for a new form). Echo a <div class="localform-settings-card" id="localform-section-..."> containing an <h3> and the tab's section menu lists it on its own - the menu is built by reading the cards on the page, so nothing needs registering. What the controls post is read back through localform_form_data / localform_form_saved. Use this for a panel of any size. |
localform_admin_settings_after_event | action | Render extra controls inside the Event Details card, at the bottom of it. Arg: $form (null for a new form). Right for another line or two about the event itself; use localform_admin_settings_after_event_card for anything bigger. Six add-on panels rendered here is what turned that one card into a column nobody could read, behind a single entry in the section menu. |
localform_event_meta_items | filter | The event facts printed in the band above the form's first question, as escaped HTML fragments keyed by fact (date, location, organizer, price). Extend a key core already fills to say more about that fact (Pro's extra dates add a +2 dates count to date; its per-audience rates append to price), add a key of your own for a new fact, or empty one to drop it. Args: $items, $form. Escape your own output - fragments pass through wp_kses() limited to <strong> and <span class>, which strips markup but not text. Use Frontend::event_meta_price( $label, $amount, $details = '' ) for an amount so it reads like core's price; $details is printed after the amount behind a dash. An empty array means no band is printed. |
localform_ics_occurrences | filter | The occasions written into a form's calendar file, one VEVENT each. Each entry is ['date' => 'Y-m-d', 'start_time' => 'H:i', 'end_date' => 'Y-m-d', 'end_time' => 'H:i', 'label' => string, 'key' => string], everything but date optional; label is appended to the entry's title, and key distinguishes one occurrence from another in the calendar UID, so it must be stable for the occasion it names (a date and time, never a position in the list) or a client will duplicate the entry when a date is added ahead of it. The form's own date is the entry with an empty key, and keeps the UID it has always had. A day with no start time becomes an all-day event; an unreadable date is dropped rather than guessed at. Pro's multiple event dates use this. Args: $occurrences, $form. Since 2.7.0. |
localform_ics_duration_minutes | filter | How long an occasion runs when it gives no end of its own (default 60). Since 2.7.0 it is only consulted for an occasion with no end time - a form that says when it finishes is taken at its word. Args: $minutes, $form. |
localform_structured_data | filter | The schema.org JSON-LD nodes printed in the head of a form's own page - core writes one Event for a form with an event date, and none at all when the form has no date or has Show event card on form page unchecked. Add nodes, change core's, or return an empty array to print nothing. Pro describes each event date as an event of its own here, and each rate as an offer. Everything returned is published to anybody who can load the page, so nothing meant only for registrants belongs in it - core deliberately leaves an online event's joining link out. Args: $nodes, $form. Since 2.7.0. |
localform_places_left | filter | How many places a form has left, as printed with the event facts and sent in the webhook payload. Core counts stored responses against Maximum registrations; return a different number where an add-on knows better (Pro counts people rather than bookings on a group form, and leaves a waiting list out of it), or null to say nothing at all. Args: $left, $form. Since 2.7.0. |
localform_currency_code | filter | The ISO 4217 code the site's currency symbol stands for, for the places a machine reads rather than a person. Core knows the unambiguous symbols; a shared one ("kr") returns '' and the price is left unpublished rather than published as the wrong money. Args: $code, $currency (the symbol as the site set it). Since 2.7.0. |
localform_forms_base_slug | filter | Change the parent slug of auto-generated pages (default forms; '' for site root). |
localform_default_settings | filter | Defaults for the plugin settings option. |
localform_show_promotions | filter | Whether the free plugin mentions the Pro add-on at all: the LocalForm → Pro page, the notice on the Forms and Responses lists, the card under the Settings section menu, the line in the admin footer of LocalForm's own screens, the "Get Pro" link on the Plugins row, and the sentence at the foot of an import report that names what Pro has. Defaults to true on a free-only install and false as soon as Pro is active. Return false to remove every one of them for good - handy for an agency handing a site to a client. The one thing it does not touch is the Powered by LocalForm credit under a form: that is off unless an administrator switched it on under Settings → General, and a filter silently cancelling somebody's own setting would make that setting a lie. No feature changes either way. Arg: $show. |
localform_show_review_request | filter | Whether the free plugin ever asks for a review: the notice on the Forms and Responses screens and the "Rate LocalForm" link on the Plugins row. Defaults to the Settings → General → Ask for a review toggle, which is on. Return false to remove both for good. No feature changes either way, and nothing is fetched or reported in any case - the link is an ordinary link to the plugin's page on WordPress.org. Arg: $show. |
Integrations (Pro)
LocalForm → Settings → Integrations is one screen for every service this site connects to, grouped by what kind of connection it is, with each one in a collapsible card that carries its own on/off switch. Payment gateways register themselves onto it; anything else registers the same way.
It replaced the Pro Payments settings screen. ?section=payments redirects
to ?section=integrations, and nothing a site had connected needed
reconnecting.
Adding an integration
add_filter( 'localform_pro_integrations', function ( $integrations ) {
$integrations['acme_crm'] = [
'label' => 'Acme CRM',
'group' => 'automation',
'description' => 'Push every response into Acme.',
'status' => [ 'state' => 'ok', 'text' => 'Connected' ],
'render' => [ Acme::class, 'render_settings' ],
'save' => [ Acme::class, 'save_settings' ],
];
return $integrations;
} );
| Key | Meaning |
|---|---|
label | The connection's name, as its own users know it. Required; an entry without one is dropped. |
group | payments, commerce, automation or other. An unrecognized group is filed under other rather than lost. |
description | One line under the heading, inside the card. |
status | ['state' => 'ok'|'warn'|'idle'|'off', 'text' => '…'] - the badge in the card's summary. |
render | Echoes the card's controls. No card wrapper, no <h3>, no <form>, no nonce, no submit button. Name inputs <id>[<key>]. |
save | Receives this integration's slice of the request, already unslashed. Sanitize for meaning; nothing here is stored or echoed unescaped for you. |
switch | Whether the card carries an on/off switch. Default true. |
switch_label | What that switch says. Defaults to "Use <label> on this site". |
classes, data | Extra classes and data-* attributes on the card element. |
What "off" means
Integrations::is_enabled( $id ) is the switch, and absent means on - the
switch was introduced after the connections were, so a site that upgrades into
the screen keeps working exactly as it did.
Off has to be honest: a payment provider that is off is not offered in the builder, is not listed as a choice, and cannot start a new payment even with valid keys stored. The credentials are kept, so switching back on is a restore rather than a reconnection.
Off must not abandon work in flight. A payment started before the switch was
flipped is still read back and still settles - the money moved, and a registrant
who paid must not be left unconfirmed because somebody tidied a settings screen.
That is why the switch is applied in Payment_Gateways::configured() (what may
start a payment) and deliberately not in Payments::refresh() (what settles
one). An integration of your own should draw the same line.
Hooks
| Hook | Type | Purpose |
|---|---|---|
localform_pro_integrations | filter | The connections shown on the screen, keyed by slug. See above. Arg: $integrations. |
localform_pro_integration_groups | filter | The groups (tabs) the screen is divided into, keyed by slug with their headings as values. Insert your slug where you want the tab to appear; an integration may then name it in its group. A group with nothing in it is never rendered, so adding one costs nothing until something lands in it. Arg: $labels. |
localform_pro_integration_group_before | action | Fires above a group's cards - where a warning that applies to the whole group goes. Payments uses it for "this site is not reachable from the internet", which no API key fixes. Arg: $group. |
localform_pro_integration_group_after | action | Fires below a group's cards - where settings belonging to the group rather than to any one provider go. Payments uses it for the default payment description. Arg: $group. |
localform_pro_integrations_saved | action | Fires after every integration has saved its own slice, for whatever ..._group_after rendered. Arg: $posted (the unslashed request; nothing in it is sanitized yet). |
Payments (Pro)
Payments are one feature with several possible providers. Everything true of taking a payment - when to charge, how much, what happens to the registration while the money is in flight, the return page, the payload, the responses column
- lives in
LocalFormPro\Paymentsand is written once. What is true of one provider lives behind theLocalFormPro\Payment_Gatewayinterface, of whichMollie_GatewayandStripe_Gatewayare the shipped implementations.
That is where the value sits. The hard part of payments is not the API call, it is holding the confirmation email until the money lands, not letting an abandoned checkout hold a seat, and converging three different ways of learning a status onto one path that is safe to run twice. Writing that a second time for a second provider is how the second provider ends up subtly worse than the first, so a gateway does none of it.
Adding a provider
Implement LocalFormPro\Payment_Gateway and register it. Everything else -
the builder panel, the settings screen, the callback route, the return page,
the reconciliation sweep - already applies to it.
add_filter( 'localform_pro_payment_gateways', function ( $gateways ) {
$gateway = new My_Gateway();
$gateways[ $gateway->id() ] = $gateway;
return $gateways;
} );
Stripe_Gateway is the worked example of doing this from outside: it is a few
hundred lines, it ships no SDK (it calls the REST API over wp_remote_request()
- see the note in
class-stripe-api.phpfor why an SDK is the wrong shape on a WordPress site), and adding it changed nothing inPayments.
| Method | What it must do |
|---|---|
id() | A stable slug, used in the callback URL, the payments table and the payload. Never changed once a site has taken a payment with it. |
label() | The provider's name as its own customers know it. |
is_configured() | Whether this site could take a payment through it right now. |
test_mode(), currency() | Recorded on the payment row, so a rehearsal is never mistaken for a real registration later. |
create_payment( $args ) | Start a payment. Must not throw - it runs inside a visitor's submit on a public page. Report failure as ['ok' => false, 'error' => '<a sentence a visitor may read>']. $args['callback_url'] is '' when this site is not reachable from outside. |
fetch_payment( $remote_id, $test ) | Ask the provider for the current status. The only thing ever believed about whether money moved. Must not throw. |
callback_payment_ids( $request ) | Which payments an incoming callback is about. Return ids, not statuses - they are looked up locally and then re-read from the provider, which is what makes the endpoint safe to leave open. |
render_settings() / save_settings( $posted ) | The provider's own controls inside its card on the Integrations settings screen. The card, its heading, its collapse and its on/off switch are drawn for you - render the controls only, no card wrapper and no <h3>. Name inputs <id>[<key>] and they arrive in save_settings(). |
check_connection( $posted ) | Powers "Test the connection", against what is in the form rather than what is saved. |
Statuses are the add-on's vocabulary, not any one provider's: open, pending,
authorized, paid, canceled, expired, failed. A gateway translates into
these; anything unrecognized is stored as open rather than getting stuck in a
status that is neither paid nor final.
Hooks
| Hook | Type | Purpose |
|---|---|---|
localform_pro_payment_gateways | filter | The providers this site can charge through, keyed by slug. See above. Arg: $gateways. |
localform_pro_payment_amount | filter | The last word on what a submission is charged, for a pricing rule that is the site's own - an early-bird window, a member discount, a per-head multiplier. Return 0 to let the registration through with no payment at all. Args: $amount, $form, $data, $config. |
localform_pro_payment_callback_url | filter | The callback URL handed to the provider. Point it at a tunnel (ngrok and friends) to receive real callbacks while developing, or return '' to switch them off. Args: $url, $gateway. LocalForm sends none at all when the site's own hostname is one the outside world cannot resolve - localhost, a private IP, .local / .test - because a provider that cannot resolve the URL refuses the payment outright. |
localform_pro_payment_return_url | filter | Where a registrant is sent back to after the checkout. The counterpart to the callback URL, and the way to take a payment while developing - point it at a tunnel's public address. Args: $url, $form, $token. The lf_payment query argument must survive: it is the only thing identifying the payment on the way back. Unlike the callback URL this one cannot be omitted - every provider requires a resolvable return URL, so Payments::can_return() is checked before the provider is called and an unreachable site fails with a sentence naming the real problem rather than the provider's own "we refused redirectUrl". |
localform_pro_payment_paid | action | A registration has been paid for. Fires exactly once per payment, on the transition into a paid status, whichever of the three routes learned it. Args: $payment (the row as it was before), $form, $data (the answers, or [] in webhook-only mode), $status. |
localform_pro_payment_failed | action | A payment will not be completed - somebody abandoned a checkout. The registration is still there. Args: $payment, $form, $data, $status (failed, canceled or expired). |
localform_pro_payment_failed_to_start | action | A payment could not be started for an accepted submission. The registration exists and the visitor has been told; this is for telling somebody who can chase it. Args: $error, $form, $submission_id, $gateway. |
Mailing lists (Pro)
The same split as payments, one layer smaller. Everything true of subscribing
somebody - whose consent, which address, what happens when the service is down,
what must never be retried - lives in LocalFormPro\Mailing_List_Subscriber and
Mailing_List_Config. What is true of one service lives behind the
LocalFormPro\Mailing_List_Provider interface, of which Flexmail_Provider,
Brevo_Provider, Mailerlite_Provider and Mailchimp_Provider are the shipped
implementations.
The generic half is again the half worth writing carefully. Two rules in it are not negotiable, and an implementation that breaks either is a bug however well it works:
- The consent gate is evaluated before anything leaves the site. A form names one of its own questions, and a gate that cannot be read - the question was deleted - is a gate that is shut. Only the explicit "subscribe everyone" sentinel opens it for a response that ticked nothing.
- An existing contact is updated, never resurrected. Somebody who
unsubscribed and later fills in a contact form has not rejoined the
newsletter.
Mailchimp_Provideris the worked example: it sendsstatus_if_newand never a plainstatus, so the status is decided for a contact being created and left alone for one that already exists.
Adding a provider
Extend LocalFormPro\Mailing_List_Client - which brings the settings storage,
the JSON transport over wp_remote_request(), the list cache and the error
reporting with it - and register the result. The consent gate, the field
mapping, the builder panel, the Integrations card and the retry already apply.
add_filter( 'localform_pro_mailing_list_providers', function ( $providers ) {
$provider = new My_List_Provider();
$providers[ $provider->id() ] = $provider;
return $providers;
} );
| Method | What it must do |
|---|---|
id() | A stable slug. It is the suffix of the settings option and what a form stores, so never change it once a site has saved a form against it. |
label() | The service's name as its own customers know it. |
list_label() | What this service calls a list, singular - Brevo says list, MailerLite says group, Mailchimp says audience, Flexmail says interest. It is the builder's label, so use the word the administrator's own dashboard uses. |
is_configured() | Whether this site has credentials for it. Credentials only - whether the administrator switched it on is Mailing_Lists::configured()'s question. |
lists() | This account's lists as id => name. Must be cheap and must never throw; an empty array means "could not ask", and the builder falls back to a box to type the id into. Mailing_List_Client caches this for you. |
subscribe( $contact ) | Add or update the contact. Must not throw - it runs after a visitor's submit. Return ['ok' => false, 'error' => '<a sentence>', 'retry' => bool], where retry is true only for something a later attempt could fix: a timeout, a 5xx, a rate limit. A rejected key is false. |
render_settings() / save_settings( $posted ) | The service's own controls inside its card on the Integrations screen, under the Email marketing tab. Same contract as a gateway's: controls only, inputs named <id>[<key>]. |
check_connection( $posted ) | Powers "Test the connection", against what is in the form rather than what is saved. Reporting how many lists were found is more useful than "it worked" - a key with the wrong permissions authenticates happily and sees nothing. |
Flexmail_Provider is the awkward worked example, and the useful one to read:
its list is an interest, subscribing takes two calls rather than one, and it
therefore has a half-succeeded case that the other three do not. The interface
accommodates it without any of them knowing.
Hooks
| Hook | Type | Purpose |
|---|---|---|
localform_pro_mailing_list_providers | filter | The services a form can subscribe to, keyed by slug. See above. Arg: $providers. |
localform_pro_mailing_list_contact | filter | The contact about to be sent - the last chance to change what leaves the site. Normalize a name, route a domain to a different list, or return [] to subscribe nobody. Args: $contact (email, first_name, last_name, list_id), $form, $data, $provider_id. |
localform_pro_mailing_list_subscribed | action | Somebody has been added to a list. Args: $contact, $provider_id. |
localform_pro_payment_needs_public_url | filter | Whether a provider needs a return URL the internet can resolve before it may be used. Args: $needs (default true), $gateway_id. Return false for a provider that completes the payment inside the site and therefore works on a laptop, an intranet or an internal staging hostname - which is what the WooCommerce gateway does. Leave it true for anything that redirects a registrant elsewhere. |
localform_pro_woocommerce_order | action | The WooCommerce order behind a registration, before it is saved. Args: $order (WC_Order, unsaved), $args (the payment details the gateway was given), $metadata (form id, submission id, payment token). For everything the gateway deliberately leaves alone: putting the fee in a tax class so VAT is broken out, adding the event date as an order note, tagging the order for a reporting plugin, filling in a billing address from answers only this site can name. |
localform_pro_mollie_currencies | filter | The currency codes the Mollie gateway offers in its settings. Arg: $currencies. |
localform_pro_stripe_currencies | filter | The currency codes the Stripe gateway offers in its settings. Arg: $currencies. |
is_configured() is about credentials and nothing else. Whether the site's
administrator has left the provider switched on is a separate question, asked
by Payment_Gateways::configured() - see "Integrations (Pro)" below. A gateway
never answers for its own switch, which is what lets a payment already in
flight settle through a provider that has since been switched off.
How a status arrives
Three routes, all converging on one idempotent path:
- The provider's callback, which carries an id and nothing else. The status behind it is read back over the API with the site's own credentials - never taken from the request - which is why the endpoint is open and why forging a call to it achieves nothing.
- The returning visitor, when they land back on the form's page. This is what makes a site the provider cannot reach work at all.
- An hourly sweep over payments nobody came back from, twenty at a time, none younger than five minutes.
A provider that settles inside the site has a fourth: Woo_Gateway hooks
woocommerce_order_status_changed and routes it through
Payment_Status::refresh(), so an order marked paid by hand lands on exactly
the same path as a Mollie callback. Its callback_payment_ids() returns [] -
nothing ever calls in, which is the one place the gateway interface assumes a
remote provider and the gateway answers by saying so.
Side effects - the held confirmation email, the second webhook, the
..._payment_paid action - hang off the transition into a paid status and are
guarded by the stored status, because all three routes can arrive in the same
second and a registrant must not be emailed three times over it.
Builder light mode
A form can hide its delivery details in the builder's Settings tab - webhook URL, redirect URL and custom fields. It is a display state, never a data one: the inputs stay in the DOM and keep posting, so a hidden setting survives a save untouched.
Two class names carry it, and an add-on that renders into the Settings tab can use both:
| Class | Where | Meaning |
|---|---|---|
is-light-mode | on .localform-builder-settings | The open form is in light mode. It is toggled live when the switch is flipped, so read it when you need it rather than caching it at load. |
localform-advanced-setting | on any element inside that panel | Hidden while light mode is on. Goes on a whole .localform-settings-card or a single <p>. |
In PHP the state is LocalForm\Form::is_light_mode( $form ); a form that has
never chosen follows LocalForm\Settings::default_light_mode().
Pro's Global Webhook Fields card is the worked example. It is injected after the
Custom Fields card and stays visible when light mode hides that card, because
the fields it holds still have to be filled in - it marks itself
localform-advanced-setting only when there are no global fields to show.
Builder features
Light mode is a per-form display choice. Alongside it sits a site-wide one:
which features the builder offers at all, ticked by an administrator under
Settings > General > Form Builder and held by LocalForm\Builder_Features.
It obeys the same rule as light mode, for the same reason - a switched-off feature is hidden, never dropped. Its inputs stay in the DOM and keep posting, so a form set up while a feature was on survives the feature being switched off, and switching it back on returns it untouched.
Register a card of your own with localform_builder_features:
add_filter( 'localform_builder_features', function ( $features ) {
$features['my_addon_thing'] = [
'label' => __( 'My thing', 'my-addon' ),
'description' => __( 'What an administrator is deciding about.', 'my-addon' ),
'default' => true,
];
return $features;
} );
Then mark the card so the switch reaches it. card_attributes() echoes the
class and data-localform-feature attributes together, including
localform-settings-card itself:
<div <?php echo \LocalForm\Builder_Features::card_attributes( 'my_addon_thing' ); ?> id="localform-section-my-thing">
Pass further classes as the second argument when a card is both switchable and an advanced setting - core's Custom Fields card is exactly that:
\LocalForm\Builder_Features::card_attributes( 'custom_fields', 'localform-advanced-setting' );
row_attributes() is the same for something smaller than a card - a single
<p> inside one, the way core marks the Tags row.
| Member | Purpose |
|---|---|
Builder_Features::registry() | Every registered feature, keyed by slug, after the filter. |
Builder_Features::is_enabled( $slug ) | Whether a feature is on for this site. An unregistered slug is on - a card with no switch is not one an administrator could bring back. |
Builder_Features::enabled() | The slugs currently on, in registry order. |
Builder_Features::card_attributes( $slug, $extra = '' ) | Escaped attributes for a whole card. |
Builder_Features::row_attributes( $slug, $extra = '' ) | The same for a row inside a card. |
Two rules keep this predictable:
- Default to
true. Installing an add-on should never silently hide something. A feature is stored only once an administrator saves the settings page, and until then it follows its registered default - which is what lets a feature added in a later version appear without anyone re-saving. - Hiding is not permission. This decides what the builder shows, never
what the plugin does. Never read
is_enabled()to decide whether to run a feature, skip validation, or leave data out of a payload; a hidden card's settings are still live. Capability checks belong inLocalForm\Capabilities.
JavaScript events (builder)
Triggered on document with jQuery, so a listener has to be registered by a
script enqueued after localform-diagram.
| Event | Purpose |
|---|---|
localform:diagram:render | The builder's diagram view finished rendering its nodes. Args: a context object - see below. Fires on every open (and on any redraw), so treat the handler as idempotent: the nodes it receives are freshly built each time. |
localform:builder-applied | A form that changed underneath the builder has been applied to it - see window.localformBuilder below. The question cards on screen are new elements by the time it fires. |
localform:view:open | A full-canvas view is taking over the Questions tab. Args: the view's name ("diagram", "preview", or your own). See below. |
localform:tab | The builder switched tabs. Args: the tab's name ("questions", "design", "responses", "settings"). Fires after the panels have been shown and hidden, so a handler can measure what is now on screen. Use it for work that is only worth doing while a panel is visible. |
localform:design:settled | A color on the Design tab has stopped changing (half a second after the last event from its picker). The picked color is already on screen; this says the values derived from it - border, quieter text shades, hover primary - are stale until something re-renders. Core answers it by rebuilding the Design tab's preview. |
The context object passed as the second argument:
| Key | What it is |
|---|---|
root | The #localform-diagram element. |
steps | Sections in order. Each has index, title, isImplicit (true for the unnamed opening step), card (the section's builder card, null when implicit), fields, and node (the rendered <section>). |
fields | Every field in document order, flattened across steps. Each has id, name, label, type, typeLabel, iconClass, isStatic, required, stepIndex, card (the builder card the model was read from), node (the rendered <li>), and meta (an empty div inside the node to render into). |
byName | The same field models keyed by field name, for resolving a rule that refers to another question. |
connect( fromNode, toNode, options ) | Queue an arrow between two nodes. options.className is added to the SVG path so an add-on can style its own edges. Nodes are DOM elements - pass field.node or step.node. |
redraw() | Re-measure and repaint every queued edge. Only needed if the handler changes the height of a node after connect(); core draws once on its own after the event. |
The model is read from the builder DOM rather than from the saved form, so it reflects unsaved edits. Anything an add-on stores outside the field cards has to be read from its own state.
$(document).on("localform:diagram:render", function (e, diagram) {
diagram.fields.forEach(function (field) {
var rule = myRules[field.name];
if (!rule) {
return;
}
field.meta.textContent = "Shown when " + rule.label;
diagram.connect(diagram.byName[rule.dependsOn].node, field.node, {
className: "my-addon-edge",
});
});
diagram.redraw();
});
Full-canvas views
The diagram and the preview both replace the question canvas rather than sitting beside it, so only one of them can be open at a time. An add-on adding a third joins that arrangement with two pieces:
- Mark the panel
localform-canvas-view(alongsidehiddenwhile it is shut). The canvas hides itself whenever any such panel is open and comes back when none is - which is what stops the canvas showing through when one view is opened straight from another. - Trigger
localform:view:openwith your view's name when yours opens, and close yours when the event names somebody else's.
$(document).on("localform:view:open", function (e, view) {
if (view !== "my-view" && isOpen) {
setOpen(false);
}
});
Sending someone to a tab
Any element inside the builder carrying data-tab-target="<tab>" switches to
that tab when clicked - no script of your own needed. The palette button in the
top bar is core's own use of it: Design used to open there as a popover, and the
button now opens the tab that replaced it.
<button type="button" data-tab-target="design">Open the design settings</button>
Keep type="button" on anything inside the builder: it sits in the form that
the next save posts.
Rich text (data-richtext)
Any <textarea> an add-on renders into a LocalForm admin screen becomes a small
formatting editor - bold, italic, link, lists - by carrying data-richtext:
printf(
'<textarea name="%s" data-richtext="1" placeholder="%s">%s</textarea>',
esc_attr( $name ),
esc_attr__( 'Shown above the first question', 'my-addon' ),
esc_textarea( $value )
);
data-richtext="minimal" is the variant used by the form description on the
canvas: the toolbar stays hidden until the field has focus.
The textarea itself never leaves the DOM - it is hidden, still named, and
rewritten on every edit - so the field posts, is matched by
localformBuilder.apply(), and keeps working unchanged if the script fails to
load. Rows added after page load are picked up on their own through a
MutationObserver; there is nothing to call. window.localformRichText.refresh( root )
exists for the case where an editor has to be attached synchronously.
What comes out is a wp_kses_post() subset, and it is the add-on's job to keep
it that way: sanitize the value with wp_kses_post() on save (or list its key
in the rich-text keys passed to Security::sanitize_request()), and escape it
with wp_kses_post() on output. sanitize_textarea_field() would flatten the
markup the editor just wrote.
window.localformBuilder
The builder publishes a small handle on window once it has started, for an
add-on that changes the form the screen is editing. Pro's assistant is the one
caller: it saves through the abilities and then brings the open builder up to
date rather than reloading it.
| Member | What it does |
|---|---|
apply( html ) | Applies a freshly rendered builder to the open one. html is the markup LocalForm\Admin::render_builder( $form_id, false ) produces - render it in an admin request (admin-ajax, not REST: it is admin markup and needs the admin includes). Questions are swapped and rebound, every other control is set to what the markup says, live previews are refreshed, and the questions that changed are marked is-updated. Returns how many changed, or null if the markup was not a builder, in which case nothing was touched. |
lock( on, label ) | Shuts the builder while the form is being written elsewhere, and says why: the whole page including the Save button goes inert under a veil carrying label. Call it with true before the write and false after apply(), so nothing can be typed in during the window where it would be replaced. |
regions | Selectors whose contents may be swapped wholesale, because their rows come from PHP and their handlers are delegated. An add-on that renders a repeater into the builder pushes its own selector: window.localformBuilder.regions.push('.my-addon-rows'). Anything not listed is value-synced by name instead, so rows it gained or lost would be missed. |
setStatus( status ) | Sets the form's draft/published state and the top-bar toggle with it. |
Two things apply() cannot know about, which the caller has to handle:
- State an add-on was given at page load. A panel drawn from
wp_localize_script()data keyed by field id is stale the moment the form changes. Answerlocalform_pro_ai_builder_datawith the fresh values and the panel refreshes with the cards. - Unsaved edits. What is applied is what was saved. Anything typed into the
builder and not saved is replaced, exactly as a reload would replace it. Hold
lock( true )for as long as the write is in flight and there is no window in which that can happen.
$(document).on("localform:builder-applied", function () {
// The question cards are new elements; anything hung off the old ones is gone.
});
Example: minimal add-on registration
add_action( 'localform_loaded', function () {
// All LocalForm\* classes and hooks are available here.
My_Addon\Feature::init();
} );