Skip to content

Developing an add-on

A SolisForms add-on is an ordinary WordPress plugin hooking onto solis_forms_register_addons. It receives the service container at boot, and so reaches the plugin’s registries without them travelling through the hook.

Skeleton

<?php
/**
 * Plugin Name: SolisForms — My add-on
 */

use SolisForms\AddOns\AddOnInterface;
use SolisForms\Core\Container;

final class My_AddOn implements AddOnInterface {

    public function get_id(): string {
        return 'my-addon';
    }

    public function get_name(): string {
        return 'My add-on';
    }

    public function boot( Container $container ): void {
        // Entry point: hook the add-on's own hooks here.
    }
}

add_action( 'solis_forms_register_addons', function ( $manager ) {
    $manager->register( new My_AddOn() );
} );

The add-on then appears in SolisForms → Add-ons. If its boot() throws, the plugin carries on loading without it and the screen reports it as failed: a faulty add-on never brings the site down.

Load your add-on after SolisForms — an ordinary plugin already is, since the hook is only emitted on init.

Adding a field type

Extending AbstractField provides the common accessible rendering (associated label, aria-required, aria-invalid, error messages): only the control itself is left.

use SolisForms\Domain\Field\AbstractField;
use SolisForms\Support\Sanitizer;

final class Colour_Field extends AbstractField {

    public function get_type(): string {
        return 'colour';
    }

    public function get_label(): string {
        return __( 'Colour picker', 'my-addon' );
    }

    public function get_settings_schema(): array {
        return array(
            'label'    => array( 'type' => 'string', 'default' => '' ),
            'required' => array( 'type' => 'boolean', 'default' => false ),
        );
    }

    public function sanitize( mixed $value ): mixed {
        return sanitize_hex_color( Sanitizer::text( $value ) ) ?? '';
    }

    public function validate( mixed $value, array $config ): array {
        return $this->validate_required( $value, $config );
    }

    protected function render_input( array $config, mixed $value, string $field_id, array $errors ): string {
        return sprintf(
            '<input type="color" class="slf-input"%s value="%s">',
            $this->common_attributes( $config, $field_id, $errors ),
            esc_attr( (string) $value )
        );
    }
}

Registration, from the add-on’s boot() or directly:

add_action( 'solis_forms_register_field_types', function ( $registry ) {
    $registry->register( new Colour_Field() );
} );

The schema declared by get_settings_schema() is enough: the React builder generates the corresponding settings interface, without a line of JavaScript. A schema of type array produces an options editor, an enum key a dropdown.

Adding front-end behaviour (JavaScript)

A field whose rendering calls for JavaScript goes through the front-end module registry. The core’s own entry point takes exactly that path — conditional logic, calculations, repeater and payment declare themselves there the way your add-on would.

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, as calculations are
    // replayed when a repeatable row appears.
    return () => update( field, context.schema );
} );

Declare the script with solis-forms-frontend as a dependency:

wp_enqueue_script(
    'my-addon-frontend',
    plugins_url( 'build/frontend.js', __FILE__ ),
    array( 'solis-forms-frontend' ),
    '1.0.0',
    true
);

Three of the registry’s rules are worth knowing:

  • Order: the third argument accepts { priority: 20 }. The submission interception is hooked at 100, after everything else.
  • Isolation: a module that throws is logged to the console and ignored. It never deprives the form of its submission.
  • Late arrival: a module declared after the page has loaded is hooked immediately onto the forms already initialised.

Borrowing the builder’s components

A panel often needs the core’s conditional-logic editor, or the name of its data store. Copying them would make two conditional-logic editors live on, and they would diverge at the rules format’s first change. The builder therefore publishes them on window.solisForms.builder:

const { ConditionalLogicEditor, STORE_NAME } = window.solisForms.builder;

Resolve the component at render time rather than when the module loads, so that an absent or older core translates into a panel without its editor, and not into an error that would take the whole builder with it. pro/assets/src/builder/core.js shows the complete procedure.

Adding a payment gateway

This extension point now belongs to the paid add-on. Payments have left the free plugin: solis_forms_payment_gateways is published by SolisFormsPro\Payments\GatewayRegistry, and an additional gateway therefore extends SolisForms Pro, not the core. The contract itself has not changed — only the namespaces are prefixed SolisFormsPro.

use SolisForms\Payments\PaymentGatewayInterface;

add_filter( 'solis_forms_payment_gateways', function ( array $gateways ) {
    $gateways['mollie'] = new My_Mollie_Gateway();
    return $gateways;
} );

My_Mollie_Gateway implements the nine methods of PaymentGatewayInterface:

MethodRole
get_id(): stringShort id, the one used in the settings and the routes
get_label(): stringName presented to the administrator
is_configured(): boolAre the necessary keys filled in
supports_subscriptions(): boolIntention to support recurring payments
create_payment( PaymentContext $c ): PaymentResultOpens the payment
confirm_payment( string $ref ): PaymentResultConfirms after the visitor returns
verify_webhook_signature( WP_REST_Request $r ): boolAuthenticates the incoming call
handle_webhook_event( WP_REST_Request $r ): voidHandles the event received
refund( string $ref, ?float $amount = null ): PaymentResultFull or partial refund

For subscriptions, implement SubscriptionGatewayInterface, which extends the previous one and adds create_subscription(), fetch_subscription() and cancel_subscription(). Returning true from supports_subscriptions() without implementing that interface is not enough: the core requires both, and sets the gateway aside rather than breaking in the middle of a subscription.

Two rules the core applies and that a gateway must not get round: the amount is recomputed server-side, never taken from the client; and a webhook’s signature is verified before any processing.

Declaring your services in the container

An add-on composing several objects does not have to keep its own factory: it declares its services in the container boot() hands it. They then become resolvable like the core’s — and therefore reusable, and replaceable by another add-on.

public function boot( Container $container ): void {
    $container->singleton(
        My_Repository::class,
        static fn (): My_Repository => new My_Repository()
    );

    $container->singleton(
        My_Renderer::class,
        static fn ( Container $c ): My_Renderer => new My_Renderer(
            $c->make( My_Repository::class ),
            $c->make( EntryValueFormatter::class )   // a core service
        )
    );

    add_action( 'init', static fn () => $container->make( My_Renderer::class )->register() );
}

pro/src/Views/ViewsAddOn.php shows a complete case: eight services, a content type, a REST route, a block, a shortcode and an admin screen.

The init priority trap

boot() runs during init, at priority 5. A subscriber added at that same priority while it is running will never be called: WP_Hook iterates a copy of the current priority’s subscriber array. The following priorities are reached perfectly well.

add_action( 'init', $callback, 5 );   // ✗ never executed
add_action( 'init', $callback );      // ✓ priority 10, not yet reached
$this->declare_now();                 // ✓ we are already inside init

What must precede your other subscribers — a register_post_type() the following ones consult — is therefore called directly from boot(): init has started, which register_post_type() requires, and the order becomes certain.

Reacting to a submission

add_action( 'solis_forms_entry_created', function ( $event ) {
    wp_remote_post( 'https://example.test/crm', array(
        'body' => array(
            'form'   => $event->form->title,
            'values' => $event->entry->values,
        ),
    ) );
} );

The event carries the complete Form object: no need to reload it from the database.

Prefer brief processing, or defer it through wp_schedule_single_event() — this hook runs during the submission request, and the user is waiting for its response.

Writing an outbound integration

Slack, Brevo and the outbound webhook live in the paid add-on (pro/src/Integrations/) and have no reserved access: they take the path described here, from the first hook to the last. pro/src/Integrations/Slack/ therefore serves as a complete and up-to-date example — it is a separate plugin, with its own autoloader and its own translation domain.

Where to put your settings

Two places, depending on the nature of the setting:

  • Account credentials — API key, token: global, through the solis_forms_settings_sections filter, read back by PluginSettings::section().
  • Form settings — recipient, template, condition: through the solisForms.formSettingsPanels JavaScript filter, read back by FormSettings::section( 'my-key' ).

No REST endpoint has to be written: the form’s settings are persisted by the generic mechanism, with the rest of the form.

Deferring the send

Never call a remote service from solis_forms_entry_created. That hook runs during the submission request, whose response the user is waiting for: a slow service would keep them waiting, a broken service would keep them waiting until the timeout. The submission is already recorded at that point; nothing justifies tying its fate to a third party’s.

public function boot( Container $container ): void {
    add_action( 'solis_forms_entry_created', function ( $event ) {
        wp_schedule_single_event(
            time(),
            'my_addon_send',
            array( $event->entry->id, $event->form_id, 1 )
        );
    } );

    add_action( 'my_addon_send', function ( $entry_id, $form_id, $attempt ) use ( $container ) {
        // Recompose the payload here, from the repositories.
    }, 10, 3 );
}

Pass ids only: copying submission values into the scheduled-tasks table would leave them there in clear.

Useful services from the container

ServiceUse
FormRepositoryInterface, EntryRepositoryInterfaceReload form and submission at send time
EntryValueFormatterpairs() returns the values with their label, options resolved and sensitive fields already set aside
MergeTagResolverResolve {field:…} in a template
ConditionalLogicEvaluatorApply a send condition, as the notifications do
OutboundDeliveryMake the call: address checked, timeout bounded, result recorded
RetryPolicyDecide on a new attempt and its delay

Going through EntryValueFormatter is not a comfort detail: it is what sets the sensitive values aside. A password read directly from $entry->values would be dispatched to the third-party service.

Recording your sends

OutboundDelivery writes into the log visible on a submission’s detail. State your own channel, failing which your sends would be attributed to the generic webhook:

$status = $container->make( OutboundDelivery::class )->send(
    new OutboundRequest(
        'my-crm',           // channel, to tell your sends apart
        $entry_id,
        $form_id,
        $url,
        'POST',
        wp_json_encode( $payload ),
        array( 'Content-Type' => 'application/json' )
    ),
    $attempt
);

if ( RetryPolicy::should_retry( $status, $attempt ) ) {
    // Reschedule with RetryPolicy::delay( $attempt ).
}

RetryPolicy only replays temporary failures: a network failure, an outage, a quota reached. A request refused for its contents will be no less refused on the third attempt.

What extends without touching the core

Beyond fields, gateways and integrations, the following filters cover the rest of the plugin. Each is detailed in hooks-reference.md.

ForFilter
Serving your own REST routessolis_forms_rest_controllers
Adding or rewriting notificationssolis_forms_notifications
Retouching an email before sending (copy, attachment)solis_forms_mail_args
Opening or closing a form by your own rulesolis_forms_form_availability
Refusing a submission the core was admittingsolis_forms_can_submit
Adding a column to the submissions tablesolis_forms_entry_columns, solis_forms_entry_column_value
Adding a row actionsolis_forms_entry_row_actions
Offering form templatessolis_forms_form_templates
Hooking front-end behaviourwindow.solisForms.registerModule()
Adding a panel to the buildersolisForms.formSettingsPanels, with window.solisForms.builder
Replacing a core sectionsolisForms.formSettingsSections
Adding a setting specific to a field typesolisForms.fieldInspectorControls
Refusing a submission before it is recordedsolis_forms_submission_errors
Handling an action posted on a submissionsolis_forms_entry_action
Adding a section to a submission’s detailsolis_forms_entry_detail_sections
Purging your data when a submission is erasedsolis_forms_entry_deleting
Adding a screen to the menuWordPress’s add_submenu_page(), with FormsListPage::SLUG as the parent

Two of them impose a deliberate limit: solis_forms_can_submit can only tighten a submission’s admission, never loosen it, and a column list stripped of its checkbox is rejected wholesale. In both cases, the core’s value takes back control.

Signposts

  • Complete list of hooks: hooks-reference.md
  • How the code is organised: architecture.md
  • Free / paid split: go-live/decoupage-gratuit-payant.md