Skip to content

Hooks reference

Every hook below is actually emitted by the code, except the one flagged at the end of the page. They are published by EventDispatcher, which prefixes the names with solis_forms_: they are ordinary WordPress hooks, consumable with add_action() / add_filter().

The actions receive a single object rather than a list of positional arguments: adding a datum to a payload then breaks no existing subscriber.

Actions

solis_forms_register_field_types

Add field types. Emitted once per request, at the registry’s first consultation.

  • Payload: SolisForms\Domain\Field\FieldTypeRegistry
  • Emitted in: FieldTypeRegistry::notify_extensions()
add_action( 'solis_forms_register_field_types', function ( $registry ) {
    $registry->register( new My_Signature_Field() );
} );

solis_forms_register_addons

Register an add-on. Emitted on init at priority 5, before the processing hooked at the default priority.

  • Payload: SolisForms\AddOns\AddOnManager
  • Emitted in: AddOnsServiceProvider::boot()
add_action( 'solis_forms_register_addons', function ( $manager ) {
    $manager->register( new My_AddOn() );
} );

solis_forms_entry_created

A submission has just been recorded. This is the hooking point for external integrations (CRM, webhook, logging).

  • Payload: Events\Payload\EntryCreatedEvent — entry, form, form_id
  • Emitted in: SubmissionHandler::handle()
  • Note: entry->values contains the values actually recorded.
add_action( 'solis_forms_entry_created', function ( $event ) {
    error_log( sprintf( 'Submission %d on form %d', $event->entry->id, $event->form_id ) );
} );

solis_forms_form_saved

A form has been created or updated through the REST API.

  • Payload: Events\Payload\FormSavedEvent — form, is_new
  • Emitted in: FormsController::create() and ::update()

solis_forms_captcha_verification_failed

The captcha verification service is unreachable or answers unusably — never when it legitimately rejects a token. The submission is refused out of caution; without a subscriber to this hook, an expired key would block all the traffic leaving no trace.

  • Payload: Events\Payload\CaptchaVerificationFailedEvent — provider, form_id, reason
  • Emitted in: AbstractCaptchaProvider

solis_forms_stats_page_rendered

The statistics page has finished rendering. An add-on can add its own reading there — that is what the friction analytics does.

  • Payload: Events\Payload\StatsPageRenderedEvent — form_id, range, from, to
  • Emitted in: StatsPage::render()
  • Note: the form has already been checked as accessible to the user, and the date bounds are already computed. Recomputing them yourself would end up diverging by a day depending on the timezone chosen.
add_action( 'solis_forms_stats_page_rendered', function ( $event ) {
    printf(
        '<p>Form %d, from %s to %s</p>',
        $event->form_id,
        esc_html( $event->from ),
        esc_html( $event->to )
    );
} );

solis_forms_addon_boot_failed

An add-on threw during its boot. The exception having been caught so as not to bring the site down, this hook is the only way to be informed of it.

  • Payload: Events\Payload\AddOnBootFailedEvent — addon_id, addon_name, exception
  • Emitted in: AddOnManager::boot_all()

Filters

solis_forms_settings_sections

Add a section to the global settings screen. An API key does not file itself per form: this is where an integration puts its own.

The section gets storage, rendering, sanitisation and its default value in one go; there is no interface to write. It is read back afterwards with PluginSettings::section( 'my-section' ).

  • Value: array<string, array{title: string, description: string|callable, fields: array<string, array{label: string, type: string, default: string, choices?: array, help?: string}>}>
  • Emitted in: SettingsRegistry::sections()
  • Field types rendered: text, password, number, select, checkbox
add_filter( 'solis_forms_settings_sections', function ( array $sections ) {
    $sections['my-crm'] = array(
        'title'       => 'My CRM',
        'description' => 'Account credentials.',
        'fields'      => array(
            'api_key' => array(
                'label'   => 'API key',
                'type'    => 'password',
                'default' => '',
            ),
        ),
    );

    return $sections;
} );

slf:step-changed (JavaScript event)

Emitted on the form at every step change, including at the first render. It lets a front-end module know where the visitor has got to without watching the pages’ hidden attribute — that is, without inferring an intent from a display detail, which breaks at the first rendering change.

  • Detail: { step, total }, both starting from 1
  • Emitted in: the core’s multistep module
  • Bubbles: yes (bubbles: true)
form.addEventListener( 'slf:step-changed', ( event ) => {
    console.log( `Step ${ event.detail.step } of ${ event.detail.total }` );
} );

solisForms.formSettingsPanels (JavaScript filter)

Add a panel to the form’s settings, in the builder. It is the interface-side counterpart to the previous filter: one carries the account credentials, the other what is set form by form.

The component receives settings — the entirety of the form’s settings — and onChange, which applies a partial change. File your values under a key of your own, read back on the PHP side by FormSettings::section().

  • Value: Array<{ name: string, render: Function }>
  • Emitted in: components/FormSettingsPanel.js
import { addFilter } from '@wordpress/hooks';

addFilter( 'solisForms.formSettingsPanels', 'my-addon/crm', ( panels ) => [
    ...panels,
    { name: 'my-crm', render: MyPanel },
] );

solis_forms_settings_tabs

Rename a tab of the settings screen, or add one.

The array is indexed by tab key; the value is the label displayed. An unknown key creates a tab, and the sections declared by solis_forms_settings_sections file themselves there by naming it.

Removing a key does not erase its sections: they fall back into the “Other” tab, rather than disappearing from a screen without anybody noticing.

  • Arguments: array<string, string> $titles
  • Emitted in: Settings\SettingsTabs::titles()
add_filter(
	'solis_forms_settings_tabs',
	static function ( array $titles ): array {
		$titles['my_module'] = 'My module';

		return $titles;
	}
);

solis_forms_payment_gateways

Add a payment gateway.

  • Value: array<string, PaymentGatewayInterface>
  • Emitted in: GatewayRegistry::notify_extensions()
add_filter( 'solis_forms_payment_gateways', function ( array $gateways ) {
    $gateways['mollie'] = new My_Mollie_Gateway();
    return $gateways;
} );

solis_forms_form_templates

Offer additional form templates. Each entry is indexed by id and supplies label, description and path (absolute path to a file in the export envelope’s format).

  • Value: array<string, array{label: string, description: string, path: string}>
  • Emitted in: TemplateRegistry::notify_extensions()
add_filter( 'solis_forms_form_templates', function ( array $templates ) {
    $templates['my-template'] = array(
        'label'       => 'My template',
        'description' => 'Preconfigured form.',
        'path'        => plugin_dir_path( __FILE__ ) . 'templates/my-template.json',
    );
    return $templates;
} );

solis_forms_rest_controllers

Serve your own REST routes. The filter receives the instances of the core’s controllers: an add-on builds its own with its own dependencies, which the core’s container does not know about. Any value that does not extend AbstractController is set aside silently — a badly configured add-on does not deprive the site of its API.

  • Value: array<int, SolisForms\Rest\AbstractController>
  • Emitted in: RestServiceProvider::controllers(), on rest_api_init
add_filter( 'solis_forms_rest_controllers', function ( array $controllers ) {
    $controllers[] = new My_Controller();
    return $controllers;
} );

solis_forms_notifications

Choose which notifications go out. The core keeps only one — the first active — and the filter receives that choice, the form and the submission. It can substitute whatever list it likes: that is how the paid add-on restores multiple notifications and conditional sending, which cannot be decided without the submission.

A value that is not an array is ignored.

  • Value: array<int, array<string, mixed>>
  • Context: SolisForms\Domain\Form, SolisForms\Domain\Entry
  • Emitted in: NotificationManager::notifications(), on sending and on resending
add_filter( 'solis_forms_notifications', function ( array $notifications, $form, $entry ) {
    $notifications[] = array(
        'id'      => 'accounts-copy',
        'enabled' => true,
        'to'      => 'accounts@example.com',
        'subject' => 'New order',
        'message' => 'See submission {entry_id}.',
    );

    return $notifications;
}, 10, 3 );

solis_forms_mail_args

Retouch an email just before it is dispatched: copy, blind copy, reply address, attachments, subject, body. Each filtered key is checked against the expected type and falls back to the core’s value if it does not match.

The delivery log records the filtered values: what remains consultable on a submission’s detail really is what was dispatched.

  • Value: array{to: string[], subject: string, message: string, is_html: bool, cc: string[], bcc: string[], reply_to: string, attachments: string[], text_alternative: string}
  • Context: the notification, Form, Entry
  • Emitted in: NotificationManager::mail_args()
add_filter( 'solis_forms_mail_args', function ( array $args ) {
    $args['bcc'][] = 'archive@example.com';
    return $args;
} );

solis_forms_form_availability

Revisit a form’s availability: waiting list, quota by role, early opening for a member. The filter can close an open form as well as reopen a closed one — availability is a business rule, not a security check. Anything that is not an AvailabilityStatus is ignored.

The check being redone at submission time, a closure pronounced here withstands a forged request.

  • Value: SolisForms\Domain\Availability\AvailabilityStatus
  • Context: SolisForms\Domain\Form
  • Emitted in: FormAvailability::filtered()
use SolisForms\Domain\Availability\AvailabilityStatus;

add_filter( 'solis_forms_form_availability', function ( $status, $form ) {
    return my_list_is_full( $form->id ) ? AvailabilityStatus::QuotaReached : $status;
}, 10, 2 );

solis_forms_can_submit

Refuse a submission the core was admitting: login required, your own quota, a blacklist.

The filter only goes one way. It is consulted only on submissions already admitted, and a value that would admit them again is ignored: an add-on can tighten the admission, never loosen it. Without that rule, an add-on would reopen in one line what the nonce and the anti-spam checks have just refused.

The submitted values are not passed: a refusal based on the contents is a matter for an anti-spam check, which SpamCheckManager knows how to host and which runs at the right moment within the rate quota.

  • Value: SolisForms\Frontend\AuthorizationOutcome
  • Context: FormSettings, int $form_id
  • Emitted in: SubmissionAuthorization::hardened()
use SolisForms\Frontend\AuthorizationOutcome;

add_filter( 'solis_forms_can_submit', function ( $outcome, $settings, $form_id ) {
    return is_user_logged_in() ? $outcome : AuthorizationOutcome::RejectedExtension;
}, 10, 3 );

solis_forms_pro_active

Silence what the core announces about the paid add-on. Answering true removes the builder’s badges, the locked panels and the “SolisForms Pro” menu entry: somebody who has paid has no use for an advertisement.

The check goes through a filter rather than through a class’s presence: the core thus needs to know nothing of the add-on’s names, and any distribution — Freemius, in-house server, lifetime licence — answers the same way.

  • Value: bool (false by default)
  • Consulted in: Support\ProFeatures::active()
add_filter( 'solis_forms_pro_active', '__return_true' );

The fields do not need this filter: a field type is only advertised if it is absent from the registry. A third-party add-on declaring rating therefore makes its badge disappear without having to say anything.

solis_forms_pro_url

Change the badges’ destination. By default, an internal screen of the plugin — no external address is hard-coded, and the plugin directs nowhere without somebody having decided it.

  • Value: string
  • Consulted in: Support\ProFeatures::url()
add_filter( 'solis_forms_pro_url', fn (): string => 'https://my-site.test/pro' );

solis_forms_pro_allowed_registration_roles

Open up the list of roles a form can assign at registration. By default, subscriber alone.

Going through code is deliberate: a form must not be able to raise the privileges of whoever fills it in, from a menu in the admin interface.

A second check applies after this filter and does not lift: a role carrying a capability that takes over the site — manage_options, edit_users, install_plugins, unfiltered_html, solis_forms_manage_settings… — stays set aside, and administrator is refused by name. The error to fear is not the administrator’s malice, it is their mistake: a role created by a third-party add-on can carry edit_users without its name giving it away.

  • Value: array<int, string> (['subscriber'] by default)
  • Consulted in: SolisFormsPro\UserAccounts\AccountRoles::allowed()
add_filter(
	'solis_forms_pro_allowed_registration_roles',
	static fn ( array $roles ): array => array_merge( $roles, array( 'member' ) )
);

solis_forms_submission_errors

Refuse a submission after the fields have been validated, just before it is recorded. It is by this path that a paid form’s payment is checked: the transaction must exist, be marked paid, be attached to no other submission and carry exactly the expected amount.

The filter can only refuse: the errors already found are not submitted to it, so it could not erase them. An extension point able to make what the core refuses acceptable would end up doing so by mistake.

  • Value: array<string, array<int, string>> — messages indexed by field id
  • Context: Form, array $values (the values kept)
  • Emitted in: SubmissionHandler::guarded()
add_filter( 'solis_forms_submission_errors', function ( array $errors, $form, array $values ) {
    if ( ! my_payment_is_compliant( $form, $values ) ) {
        $errors['payment'] = array( 'The payment does not match.' );
    }

    return $errors;
}, 10, 3 );

The counterpart to this guard is solis_forms_entry_created: that is where what the guard verified gets attached to the submission.

solis_forms_draft_saved

Published after a draft has been saved.

The core records, cleans and signs; an add-on wanting to enrich a draft — with a consent, with a scheduled reminder — does not have to reproduce those checks for all that. It listens, and works on what the core has already kept.

created distinguishes the first save from the following ones: an automatic save renewed every two minutes must not be treated as the discovery of an entry.

  • Payload: Events\Payload\DraftSavedEvent — entry_id, form, values, created
  • Published in: Frontend\SubmissionHandler::save_draft()
add_action(
	'solis_forms_draft_saved',
	static function ( SolisForms\Events\Payload\DraftSavedEvent $event ): void {
		if ( $event->created ) {
			// First save of this entry.
		}
	}
);

solis_forms_submission_values

The last word on the submitted values, before they are validated.

Used for what the server decides and the browser cannot choose: the value of a locked field, for instance, which a forged request would have replaced.

The filter applies before validation: a value substituted here takes exactly the same path as a visitor’s, and therefore gets round no field check.

  • Value: array<string, mixed>
  • Context: the Form submitted
  • Consulted in: Frontend\SubmissionHandler::handle()
add_filter(
	'solis_forms_submission_values',
	static function ( array $values ): array {
		$values['source'] = 'internal';

		return $values;
	}
);

solis_forms_pro_prefill_value

Paid add-on. Supplies the value of a prefill from a business source.

It is the only place provided for hooking up a source of the site’s own: it lives in code, under the responsibility of whoever writes it. What the filter returns then goes through the target field type’s sanitisation.

  • Value: string (empty by default)
  • Context: the declared key, the field’s id, the Form
  • Consulted in: Prefill\PrefillResolver
add_filter(
	'solis_forms_pro_prefill_value',
	static function ( string $value, string $key ): string {
		return 'case_number' === $key ? my_case_number() : $value;
	},
	10,
	2
);

solis_forms_pro_ai_providers

Paid add-on. Add a completion provider to the admin assistant.

The module ships one. Shipping several would mean maintaining several shapes of request, response and error, for a feature that has to stay optional — so this filter opens the door to whoever wants their own, as the plugin already does for payment gateways and field types.

A value that does not implement AiProviderInterface is set aside silently: a badly configured third-party module does not deprive the site of its assistant.

The contract is deliberately narrow — a provider receives a text and returns a text. It knows nothing of forms or fields: composing the request belongs to PromptBuilder, judging the answer to AiSuggestionValidator. A third-party provider therefore has no way to bring in anything but a text, which is rebuilt key by key before it reaches a screen.

  • Value: array<string, AiProviderInterface>
  • Emitted in: Ai\AiProviderRegistry::all()
add_filter(
	'solis_forms_pro_ai_providers',
	static function ( array $providers ): array {
		$providers['my-service'] = new My_Provider();

		return $providers;
	}
);

solis_forms_pro_personal_data_providers

Paid add-on. Declare a module that holds data tied to a person, so that it enters the privacy centre’s export and erasure.

The centre does not sweep the tables. It could — they are all prefixed slf_ — but it would have to know which column carries the address in each: twenty copies of the same knowledge in the wrong place, and a module added tomorrow would be forgotten without the omission showing. An incomplete export looks complete.

Each module therefore declares what it holds. One that does not declare appears neither in the export nor in the erasure — and that is observable, since the registry is listed on screen.

erase is not necessarily a deletion: a module that has to keep a trace — an accounting entry, a payment — returns what it anonymised rather than deleted, and says so. The centre does not decide in its place; it reports.

Everything is idempotent: a request is replayed after a partial failure, and a module with nothing left returns an empty, successful result. Without that, the second run would fail on what the first had correctly erased.

  • Value: array<int, PersonalDataProviderInterface>
  • Emitted in: Privacy\PersonalDataRegistry::all()
add_filter(
	'solis_forms_pro_personal_data_providers',
	static function ( array $providers ): array {
		$providers[] = new My_Provider();

		return $providers;
	}
);

solis_forms_form_pages

Recompose a form’s pages before they are rendered.

The core’s splitting follows the page breaks, and nothing else. A module wanting to present one question per screen had, without this filter, only one recourse: redoing the markup in JavaScript afterwards — and therefore rewriting the navigation, the progress and the focus handling the core already holds, and letting them diverge.

A page is an array {title, fields}. Returning anything other than a list of valid pages falls back to the core’s splitting: a form must display even if an add-on gets it wrong.

  • Value: array<int, array{title: string, fields: array}>
  • Context: the Form rendered
  • Consulted in: Frontend\FormRenderer::pages()
add_filter(
	'solis_forms_form_pages',
	static function ( array $pages, SolisForms\Domain\Form $form ): array {
		// One question per screen.
		return array_merge( ...array_map(
			static fn ( array $page ): array => array_map(
				static fn ( array $field ): array => array(
					'title'  => '',
					'fields' => array( $field ),
				),
				$page['fields']
			),
			$pages
		) );
	},
	10,
	2
);

solis_forms_form_markup

Wrap a rendered form’s markup.

Used for what surrounds the form without belonging to it: a shell, a dedicated page’s header, a container carrying colours. The form’s own markup stays the core’s.

Returning anything other than a string, or an empty string, falls back to the original markup: an add-on that gets it wrong must not make the form disappear from the page, because the breakage would be total and silent.

  • Value: string
  • Context: the Form rendered
  • Consulted in: Frontend\FormRenderer::render_form()
add_filter(
	'solis_forms_form_markup',
	static fn ( string $markup ): string => '<div class="my-shell">' . $markup . '</div>'
);

solis_forms_entry_action

Handle an action posted from a submission’s detail — refund, stop a subscription.

The generic checks are done before publication: the capability to view submissions, and access to the form. What is specific to the action remains your responsibility: its nonce, and its particular capability. Refunding commits the site’s account, stopping a recurring commitment cannot be caught up; neither is checked the way a consultation is.

  • Payload: Events\Payload\EntryActionEvent — action, entry, form
  • Emitted in: EntryActionHandler::handle()

solis_forms_entry_detail_sections

Add a section to a submission’s detail screen, between the answers and the status. Each section receives the submission and its form, and returns its markup — escaping it is its own business.

  • Value: array<string, callable> — fn ( Entry $entry, Form $form ): string
  • Context: Entry, Form
  • Emitted in: EntryDetailPage::render_extension_sections()

solis_forms_entry_status_changed

A submission’s status has just changed: trash, spam, restoration.

Published from the repository and not from the screens, of which several already change a status — the list, the detail, the bulk action, the REST API. A guarantee depending on each caller remembering it is not one.

And only if the value changes. Setting a status that is already written publishes nothing: announcing it would force every subscriber to re-read the row to find out whether anything had moved, which is to say to redo the work the event exists to avoid.

The state left travels with the new one, because it cannot be inferred: a submission can go from the trash to spam without passing back through active.

  • Payload: Events\Payload\EntryStatusChangedEvent — entry_id, from, to
  • Emitted in: Database\Repository\EntryRepository::update_status()
add_action(
	'solis_forms_entry_status_changed',
	static function ( \SolisForms\Events\Payload\EntryStatusChangedEvent $event ): void {
		if ( \SolisForms\Domain\EntryStatus::Active !== $event->to ) {
			my_module_releases( $event->entry_id );
		}
	}
);

solis_forms_entry_deleting

Purge what an add-on files per submission. Published before the row disappears: the subscriber can therefore still read what it has to erase.

Any add-on storing data per submission must subscribe to it. A deletion under GDPR must leave nothing behind it, and the core does not know about other people’s tables.

  • Payload: Events\Payload\EntryDeletingEvent — entry_id
  • Emitted in: EntryRepository::delete()

solis_forms_entry_columns

Add a column to the submissions table. A filtered list that had lost the cb key is rejected wholesale: without a checkbox, the screen would lose its bulk actions.

  • Value: array<string, string> — column id, header
  • Context: Form|null
  • Emitted in: EntriesListTable::get_columns()

solis_forms_entry_column_value

Fill in an added column. The value returned is displayed as it is: escaping it is the add-on’s business.

  • Value: string
  • Context: string $column, Entry, Form|null
  • Emitted in: EntriesListTable::column_default()
add_filter( 'solis_forms_entry_columns', function ( array $columns ) {
    $columns['amount'] = 'Amount';
    return $columns;
} );

add_filter( 'solis_forms_entry_column_value', function ( $value, $column, $entry ) {
    return 'amount' === $column ? esc_html( my_amount( $entry->id ) ) : $value;
}, 10, 3 );

solis_forms_entry_row_actions

Add a row action. The core’s actions are already reduced to the user’s rights: an add-on adding its own does not have to redo that check, and does not reintroduce one either.

  • Value: array<string, string> — id, HTML link
  • Context: Entry, Form|null
  • Emitted in: EntriesListTable::build_row_actions()

solis_forms_pro_workflow_transitioned

Paid add-on. A processing case has just changed state.

Published after the write, and only if it took place: a refused transition, or one lost in a revision conflict, emits nothing. What listens can therefore trust the event without re-reading the row to find out whether it moved.

The state left may be a key the configuration no longer declares — a state removed from the settings stays written on the cases that were in it.

  • Arguments: int $entry_id, string $from, string $to
  • Emitted in: Workflows\WorkflowService::update()
add_action(
	'solis_forms_pro_workflow_transitioned',
	static function ( int $entry_id, string $from, string $to ): void {
		if ( 'resolved' === $to ) {
			my_dashboard_records( $entry_id );
		}
	},
	10,
	3
);

solis_forms_pro_workflow_assigned

Paid add-on. A processing case has just been assigned to somebody.

Published before the “notify the assignee” setting, and independently of it: a team may want the announcement in its channel without wanting the individual message. Tying them together would have forced you to switch one on in order to get the other.

The deadline is the case’s at the moment of assignment, or null if it carries none.

  • Arguments: int $entry_id, int $form_id, int $assignee_id, string|null $due_at
  • Emitted in: Workflows\WorkflowService::assign()
add_action(
	'solis_forms_pro_workflow_assigned',
	static function ( int $entry_id, int $form_id, int $assignee_id, ?string $due_at ): void {
		my_channel_announces( $entry_id, $assignee_id, $due_at );
	},
	10,
	4
);

solis_forms_pro_workflow_overdue

Paid add-on. A case has passed its deadline.

Published after the reminder has been claimed, and therefore only once per deadline passed: the hourly check runs on every site in the network and the row claim lets only one process through.

It exists so that a module can give notice somewhere other than by email without going and reading the cases table, which would have tied two modules that nothing guarantees are active together.

  • Arguments: int $entry_id, int $form_id, int $assignee_id, string|null $due_at
  • Emitted in: Workflows\WorkflowService::remind_overdue()
add_action(
	'solis_forms_pro_workflow_overdue',
	static function ( int $entry_id, int $form_id, int $assignee_id, ?string $due_at ): void {
		my_channel_alerts( $entry_id, $due_at );
	},
	10,
	4
);

solis_forms_pro_booking_reserved

Paid add-on. A slot has just been booked.

Published after the booking and its notification, and therefore only once per place obtained: the claim returns null when the place is already taken, and the module stops before getting that far.

It exists so that a module can react — the first to use it sends a confirmation SMS — without going and reading the bookings table, which would have tied two modules that nothing guarantees are active together.

The slot is passed as it was submitted, and not reformatted: what listens decides its own display.

  • Arguments: int $entry_id, int $form_id, string $slot
  • Emitted in: Bookings\BookingsAddOn::on_entry_created()
add_action(
	'solis_forms_pro_booking_reserved',
	static function ( int $entry_id, int $form_id, string $slot ): void {
		my_module_confirms( $entry_id, $slot );
	},
	10,
	3
);

solis_forms_pro_recovery_reminded

Paid add-on. An abandoned draft has just been followed up by email.

Emitted only if the email went out: a module doubling the reminder with an SMS must not send it when the reminder itself failed, failing which the person receives a reminder without the link it announces.

Emitted after the module’s consent check: a reminder only takes place if the person explicitly accepted it, and what grafts itself onto it inherits that agreement.

  • Arguments: int $entry_id, int $form_id
  • Emitted in: Abandonment\RecoveryService::remind()
add_action(
	'solis_forms_pro_recovery_reminded',
	static function ( int $entry_id, int $form_id ): void {
		my_module_doubles_the_reminder( $entry_id );
	},
	10,
	2
);

solis_forms_pro_booking_cancelled

Paid add-on. A slot booking has just been cancelled.

Emitted from the repository and not at the callers’, of which there are already two — the public link and the admin screen. A guarantee depending on each caller remembering it is not one: the first one you forgot would leave a cancelled appointment in somebody’s calendar.

A replayed cancellation does not emit it twice: the condition bears on the number of rows actually modified.

  • Arguments: int $booking_id, int $entry_id, int $form_id
  • Emitted in: Database\Repository\BookingRepository::cancel()
add_action(
	'solis_forms_pro_booking_cancelled',
	static function ( int $booking_id, int $entry_id, int $form_id ): void {
		my_calendar_removes( $booking_id );
	},
	10,
	3
);

solis_forms_pro_booking_promoted

Paid add-on. A booking on the waiting list has just obtained a place.

A promoted place is an appointment like any other: it deserves to enter a calendar, which a waiting place did not. That is this event’s reason for being, distinct from that of a booking confirmed straight away.

  • Arguments: int $booking_id, int $entry_id, int $form_id
  • Emitted in: Database\Repository\BookingRepository::promote()
add_action(
	'solis_forms_pro_booking_promoted',
	static function ( int $booking_id, int $entry_id, int $form_id ): void {
		my_calendar_places( $booking_id );
	},
	10,
	3
);

solis_forms_process_entries (meta capability)

Paid add-on. The right to process a form’s submissions.

Used with the form’s id as an argument. It is translated by map_meta_cap into two conditions the site already grants — viewing submissions, and having authority over that form — and is therefore assigned to nobody: an updated site loses no access.

It is on it, and not on the assignment, that access rests: being designated on a case never gives the right to read it. The list of assignable people is derived from it.

A site wanting a distinct “processor” role rehooks it with a higher-priority filter, without touching the code.

  • Declared in: Workflows\WorkflowAccess::register()
add_filter(
	'map_meta_cap',
	static function ( array $caps, string $cap, int $user_id, array $args ): array {
		if ( 'solis_forms_process_entries' !== $cap ) {
			return $caps;
		}

		return user_can( $user_id, 'my_processor_role' ) ? array( 'read' ) : $caps;
	},
	20,
	4
);

solis_forms_pro_portal_decided

Paid add-on. A submission has just changed publication state in a portal.

Published after the write, and only if it took place: a refused transition, or one lost in a revision conflict, emits nothing. What listens can therefore trust the event without re-reading the decision to find out whether it moved.

The decision bears on the portal/submission pair: the same entry can be approved here and refused there, and the event is emitted once per portal.

  • Arguments: int $entry_id, int $view_id, string $from, string $to
  • Emitted in: Views\PortalModerationService::decide()
add_action(
	'solis_forms_pro_portal_decided',
	static function ( int $entry_id, int $view_id, string $from, string $to ): void {
		if ( 'approved' === $to ) {
			my_directory_refreshes( $view_id );
		}
	},
	10,
	4
);

solis_forms_moderate_form (meta capability)

Paid add-on. The right to decide what appears in a form’s portals.

Used with the form’s id as an argument. It first requires the solis_forms_moderate_entries capability, then the scope: all forms for whoever manages them, their own for whoever only manages their own.

The order matters. Requiring the scope first would have let any forms manager moderate without it having been granted to them, when that is precisely what this capability serves to separate: moderating is neither modifying the form, nor refunding a payment, nor cancelling a subscription.

  • Declared in: Support\FormAccess::register()
if ( current_user_can( 'solis_forms_moderate_form', $form_id ) ) {
	// …
}

Zapier subscription routes (REST API)

Paid add-on. Four routes under solis-forms/v1, used by the Zapier client and by nothing else.

They are documented here because they are public in the API’s sense: a third-party add-on can hook onto them, and an integrator may want to build their own client rather than going through the marketplace’s.

RouteRole
GET /zapier/formsForms whose trigger is switched on, for a menu
POST /zapier/subscriptions{form_id, target_url} → {id, secret}
DELETE /zapier/subscriptions/<id>Switches a Zap off
GET /zapier/sample?form_id=<id>A sample item, manufactured

Each requires two checks: the solis_forms_view_entries capability and access to the form targeted. The first alone would let a manager of their own forms subscribe a Zap to somebody else’s, by changing a number.

The sample is never a real submission. Zapier keeps it in the Zap’s configuration, where it would outlive the erasure of the data.

The secret returned at subscription is stored nowhere: it is rederived from the site’s salt and the subscription’s row number at every signature.

window.solisForms.registerModule() (JavaScript registry)

Hook front-end behaviour onto every form on the page. The core’s entry point goes through this same registry: a field whose rendering calls for JavaScript therefore has nothing special to obtain.

bind receives the form and its context — context.schema describes the fields, context.refresh() replays the declared re-evaluations. A module returning a function sees it added to those re-evaluations. The priority orders the hooking (10 by default, the submission interception being at 100). A module that throws is logged and ignored: it does not deprive the form of its submission.

Declare your script with solis-forms-frontend as a dependency, so that it runs after the registry.

context.hold( () => message | null ) holds the submission back: the payment module uses it to prevent sending until the payment is confirmed. The core consults the holds before sending and displays the first message returned — it therefore does not have to know what is holding the send back.

window.solisForms.collectFormValues( form ) goes with it: a module reacting to input needs to read the form, and that reading knows the plugin’s naming conventions — repeatable row indices, multiple checkboxes. Copying it would make two readings of the same structure live on. pro/assets/src/frontend/core.js shows how it is borrowed.

window.solisForms.registerModule( 'my-addon/signature', ( form, context ) => {
    const field = form.querySelector( '[data-signature]' );

    if ( ! field ) {
        return;
    }

    // Returning a function has context.refresh() replay it.
    return () => update( field, context.schema );
} );

solisForms.formSettingsSections (JavaScript filter)

Replace a section of the form settings panel, where solisForms.formSettingsPanels adds one. An add-on enriching an existing feature needs it: without that, the settings of one and the same object would end up split between two panels — one carrying the email’s subject, the other its blind copy.

The filter receives an empty object and returns the replacements indexed by section name. The substituted component receives the same properties as an added panel: settings and onChange.

  • Value: Record<string, Function>
  • Core sections: notifications, confirmation, anti-spam, save-resume, availability
  • Emitted in: components/FormSettingsPanel.js
import { addFilter } from '@wordpress/hooks';

addFilter(
    'solisForms.formSettingsSections',
    'my-addon/notifications',
    ( sections ) => ( { ...sections, notifications: MyEditor } )
);

solisForms.fieldInspectorControls (JavaScript filter)

Add a setting specific to a field type, in the builder’s inspector.

The schema declared by get_settings_schema() generates most of that column, and is enough for a simple control. It cannot describe a control that needs the rest of the form — a calculation formula, which offers the neighbouring fields’ ids. That is what this filter allows.

Each control receives the selected field and applies a partial change; it is its own business to return nothing for a type that does not concern it.

  • Value: Array<{ name: string, render: Function }>
  • Emitted in: components/Inspector.js
addFilter(
    'solisForms.fieldInspectorControls',
    'my-addon/signature',
    ( controls ) => [ ...controls, { name: 'signature', render: MyEditor } ]
);

function MyEditor( { field, onChange } ) {
    if ( 'signature' !== field.type ) {
        return null;
    }

    return <MyControl onChange={ ( style ) => onChange( { style } ) } />;
}

window.solisForms.builder (builder API)

What the builder publishes for add-ons to use, when its bundle loads:

KeyContents
ConditionalLogicEditorThe core’s conditions editor, as the form’s panels use it
MERGE_TAGSMerge tokens recognised by the server, to remind a message’s author of them
STORE_NAMEName of the builder’s @wordpress/data store, to read the current form’s fields

An add-on panel needs it as soon as it offers conditional sending: copying the editor would make two implementations of the same rules format live on. Declare solis-forms-builder as a dependency of your script, and resolve the component at render time — an absent core must deprive the panel of its editor, never take the builder with it.

  • Published in: assets/src/builder/expose.js, called by builder/index.js
  • Complete example: pro/assets/src/builder/core.js
function ConditionalLogicEditor( props ) {
    const Editor = window.solisForms?.builder?.ConditionalLogicEditor;

    return Editor ? <Editor { ...props } /> : null;
}

Declared but not emitted

EventNames::PAYMENT_COMPLETED (solis_forms_payment_completed) exists among the constants but no code publishes it to this day. Do not subscribe to it: it will not fire. A payment’s state is observed today through solis_forms_entry_created, the submission only being recorded once the transaction has been verified.