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_gatewaysis published bySolisFormsPro\Payments\GatewayRegistry, and an additional gateway therefore extends SolisForms Pro, not the core. The contract itself has not changed — only the namespaces are prefixedSolisFormsPro.
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:
| Method | Role |
|---|---|
get_id(): string | Short id, the one used in the settings and the routes |
get_label(): string | Name presented to the administrator |
is_configured(): bool | Are the necessary keys filled in |
supports_subscriptions(): bool | Intention to support recurring payments |
create_payment( PaymentContext $c ): PaymentResult | Opens the payment |
confirm_payment( string $ref ): PaymentResult | Confirms after the visitor returns |
verify_webhook_signature( WP_REST_Request $r ): bool | Authenticates the incoming call |
handle_webhook_event( WP_REST_Request $r ): void | Handles the event received |
refund( string $ref, ?float $amount = null ): PaymentResult | Full 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_sectionsfilter, read back byPluginSettings::section(). - Form settings — recipient, template, condition: through the
solisForms.formSettingsPanelsJavaScript filter, read back byFormSettings::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
| Service | Use |
|---|---|
FormRepositoryInterface, EntryRepositoryInterface | Reload form and submission at send time |
EntryValueFormatter | pairs() returns the values with their label, options resolved and sensitive fields already set aside |
MergeTagResolver | Resolve {field:…} in a template |
ConditionalLogicEvaluator | Apply a send condition, as the notifications do |
OutboundDelivery | Make the call: address checked, timeout bounded, result recorded |
RetryPolicy | Decide 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.
| For | Filter |
|---|---|
| Serving your own REST routes | solis_forms_rest_controllers |
| Adding or rewriting notifications | solis_forms_notifications |
| Retouching an email before sending (copy, attachment) | solis_forms_mail_args |
| Opening or closing a form by your own rule | solis_forms_form_availability |
| Refusing a submission the core was admitting | solis_forms_can_submit |
| Adding a column to the submissions table | solis_forms_entry_columns, solis_forms_entry_column_value |
| Adding a row action | solis_forms_entry_row_actions |
| Offering form templates | solis_forms_form_templates |
| Hooking front-end behaviour | window.solisForms.registerModule() |
| Adding a panel to the builder | solisForms.formSettingsPanels, with window.solisForms.builder |
| Replacing a core section | solisForms.formSettingsSections |
| Adding a setting specific to a field type | solisForms.fieldInspectorControls |
| Refusing a submission before it is recorded | solis_forms_submission_errors |
| Handling an action posted on a submission | solis_forms_entry_action |
| Adding a section to a submission’s detail | solis_forms_entry_detail_sections |
| Purging your data when a submission is erased | solis_forms_entry_deleting |
| Adding a screen to the menu | WordPress’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
