Builder Concepts

Form Hooks

Attach custom side-effects to form submissions — log, record consents, notify Slack — without modifying the primary action.

Form Hooks

Form hooks let you attach multiple side-effects to a form submission without touching the primary action. The action owns the result — success or failure, redirect, response message — while hooks exist purely to react to a successful submission: write to your database, notify a Slack channel, sync to a CRM, fire a webhook, or record GDPR consent. Because hooks run after the action and their exceptions are swallowed, they can never break the user-facing flow.

How hooks fit into the submission lifecycle

When a visitor submits a form, the FormSubmissionController runs this sequence:

  1. Validate — Server-side validation rules derived from the form schema.
  2. Dispatch action — The configured action class (e.g. SendEmailAction) runs and returns a FormActionResult. If the action throws, the sequence stops here and hooks do not run.
  3. Run hooks — Every hook registered for this form runs in the order they were attached, receiving the same $formData and $submitConfig. Exceptions inside hooks are caught, reported, and do not halt subsequent hooks.
  4. Return response — The JSON result (success, message, redirectUrl) is sent to the browser based solely on the action result.

The key constraint: hooks are fire-and-forget relative to the response. They must not be used for anything that can cause a submission to fail from the user's perspective. Use the action for that.

Octane / Swoole: hooks run synchronously in the same request cycle. If a hook is slow (e.g. an external HTTP call), it delays the response. For latency-sensitive forms, dispatch a queued job from inside the hook rather than calling the external API directly.

Anatomy of a hook class

Every hook extends Ccast\Tagixo\FormBuilder\Hooks\FormHook and implements four members:

<?php

namespace App\FormHooks;

use Ccast\Tagixo\FormBuilder\Hooks\FormHook;

class MyHook extends FormHook
{
    // Unique kebab-case identifier stored in the form's submit config.
    // Changing this key after deployment orphans existing attachments.
    public static function getKey(): string
    {
        return 'my-hook';
    }

    // Human-readable label shown in the builder's hook multi-select.
    public static function getLabel(): string
    {
        return 'My Hook';
    }

    // Any Heroicons v2 outline icon slug.
    public static function getIcon(): string
    {
        return 'heroicon-o-bolt';
    }

    // Called once per submission, after the action succeeds.
    public function handle(array $formData, array $submitConfig): void
    {
        // side-effect goes here
    }
}

The handle() parameters

$formData

Associative array of all named form fields after validation. The keys are the name attributes of the form controls.

[
    'first_name' => 'Maria',
    'last_name'  => 'Bianchi',
    'email'      => 'maria@example.com',
    'newsletter' => 'on',       // checkbox checked
    'phone'      => '',          // optional, empty string when blank
]

Optional fields that were not filled are present with an empty string or null depending on the field type. Always use $formData['key'] ?? null rather than relying on presence.

$submitConfig

The raw configuration array of the submit block as stored in the form schema:

[
    'action'          => 'send-email',
    'hooks'           => ['gdpr-consent', 'crm-sync'],
    'success_message' => 'Thank you! We will be in touch.',
    'redirect_url'    => null,
    // action-specific keys follow (to, subject, template_id, …)
]

Accessing request context

$formData and $submitConfig contain only what was submitted and configured. For IP address, user agent, locale, or route parameters, use request() directly inside handle() — the request object is available for the full lifecycle of the synchronous hook.

public function handle(array $formData, array $submitConfig): void
{
    $ip        = request()->ip();
    $userAgent = request()->userAgent();
    $formKey   = request()->route('formKey');   // the form's slug
    $locale    = app()->getLocale();
}

Creating a hook

1. Generate the stub

php artisan make:tagixo-form-hook YourHookName
# With a custom icon:
php artisan make:tagixo-form-hook YourHookName --icon=heroicon-o-arrow-up-right

This creates app/FormHooks/YourHookName.php.

2. Implement the class

Fill in the four required members — getKey(), getLabel(), getIcon(), and handle() — as shown in the anatomy section above.

3. Register the hook

Via FormHookRegistry in a service provider

// app/Providers/AppServiceProvider.php

use App\FormHooks\GdprConsentHook;
use App\FormHooks\SlackNotificationHook;
use App\FormHooks\CrmWebhookHook;
use Ccast\Tagixo\FormBuilder\Hooks\FormHookRegistry;

public function boot(): void
{
    $registry = app(FormHookRegistry::class);

    $registry->register(GdprConsentHook::class);
    $registry->register(SlackNotificationHook::class);
    $registry->register(CrmWebhookHook::class);
}

Via the Tagixo facade

You can also use the facade, which forwards to the same registry:

use Ccast\Tagixo\Facades\Tagixo;

Tagixo::registerFormHook(GdprConsentHook::class);

Both approaches are equivalent. Prefer the service provider pattern for discoverability. The facade form is convenient when shipping hooks from inside a Laravel package that does not have access to the consuming app's service providers.

Registered hooks appear immediately in the builder's Submit Button → Design → Hooks multi-select. Removing a registration from the provider does not remove it from existing form configs — orphaned hook keys are silently skipped at dispatch time.

Registration method Where to use
FormHookRegistry::register() via AppServiceProvider Standard app-level hooks
Tagixo::registerFormHook() Hooks shipped inside a reusable Laravel package

Attaching hooks to a form

In the Form Builder: open the form → select the Submit Button block → open the Design tab → locate the Hooks field. The field is a multi-select of all registered hooks; pick one or more. Order matters — hooks run top-to-bottom in the order they appear in the multi-select.

The Hooks field is hidden entirely when no hooks are registered. If you do not see it, check that your service provider is bootstrapped and the registry is populated before the builder loads.

Error handling

When a hook throws, Tagixo calls report($exception) and moves to the next hook. The submission response is not affected — the browser still receives the action's success result.

Laravel's report() honours your configured LOG_CHANNEL and any exception reporting integrations (Sentry, Flare, Bugsnag) registered via $app->bind(ExceptionHandler::class, …). No additional wiring is needed.

[2026-07-15 14:03:22] local.ERROR: Slack webhook call failed {"exception":"[...] cURL error 28"}
[2026-07-15 14:03:22] local.INFO: Form submitted {"form_key":"contact"}

The order of log entries above reflects the runtime sequence: the exception is reported, then the next hook (in this case, a logging hook) runs normally.

If a hook failing must abort the submission, that logic belongs in the action, not a hook. Hooks are not designed to gate the success response.

Preventing silent failures in production

Because hook exceptions are swallowed, monitor them via your exception tracker rather than by watching form success rates. Consider adding explicit error context inside hooks that touch external services:

public function handle(array $formData, array $submitConfig): void
{
    try {
        Http::timeout(5)->post(config('services.crm.webhook'), $formData);
    } catch (\Throwable $e) {
        // report() is called by Tagixo, but you can add your own context first:
        report($e->withContext([
            'hook'  => static::getKey(),
            'email' => $formData['email'] ?? null,
        ]));
    }
}

Hook ordering

Hooks run in the order they appear in the form's hooks array (as set by the builder's multi-select). There is no separate priority field. To change execution order, reorder the items in the multi-select.

If two hooks are independent (e.g. logging and CRM sync), order does not matter. If one hook depends on the side-effect of another (rare), place the dependency first.

Async / queued hooks

Tagixo does not natively support async hook dispatch. To run a hook off the request cycle, dispatch a Laravel job from inside handle():

public function handle(array $formData, array $submitConfig): void
{
    \App\Jobs\SyncContactToCrm::dispatch(
        $formData,
        request()->ip(),
    );
}

The job is queued via whatever driver is configured (QUEUE_CONNECTION). With Octane/Swoole in production, dispatching a job adds negligible latency to the hook itself. The queue worker must be running separately — in the default Supervisor config for this project, [program:queue] handles it.

When queueing from a hook, be aware the queued job runs outside the HTTP request and does not have access to request(). Pass all needed data as constructor arguments to the job.

Examples

Log form submissions

public function handle(array $formData, array $submitConfig): void
{
    \Illuminate\Support\Facades\Log::info('Form submitted', [
        'form_key'  => request()->route('formKey'),
        'form_data' => $formData,
    ]);
}

Record GDPR consents

public function handle(array $formData, array $submitConfig): void
{
    \App\Models\UserConsent::create([
        'form_key'     => (string) request()->route('formKey'),
        'ip_address'   => request()->ip(),
        'user_agent'   => request()->userAgent(),
        'consents'     => $formData,
        'consented_at' => now(),
    ]);
}

Send a Slack notification

public function handle(array $formData, array $submitConfig): void
{
    \Illuminate\Support\Facades\Http::post(config('services.slack.webhook'), [
        'text' => 'New submission from ' . ($formData['email'] ?? 'anonymous'),
    ]);
}

Complete example: lead-capture form with three hooks

A form that records GDPR consent to the database, sends a Slack notification, and syncs the contact to an external CRM via webhook.

GdprConsentHook

<?php

namespace App\FormHooks;

use App\Models\UserConsent;
use Ccast\Tagixo\FormBuilder\Hooks\FormHook;

class GdprConsentHook extends FormHook
{
    public static function getKey(): string   { return 'gdpr-consent'; }
    public static function getLabel(): string { return 'Record GDPR Consent'; }
    public static function getIcon(): string  { return 'heroicon-o-shield-check'; }

    public function handle(array $formData, array $submitConfig): void
    {
        UserConsent::create([
            'form_key'     => (string) request()->route('formKey'),
            'ip_address'   => request()->ip(),
            'user_agent'   => request()->userAgent(),
            'payload'      => $formData,
            'consented_at' => now(),
        ]);
    }
}

SlackNotificationHook

<?php

namespace App\FormHooks;

use Ccast\Tagixo\FormBuilder\Hooks\FormHook;
use Illuminate\Support\Facades\Http;

class SlackNotificationHook extends FormHook
{
    public static function getKey(): string   { return 'slack-notification'; }
    public static function getLabel(): string { return 'Slack Notification'; }
    public static function getIcon(): string  { return 'heroicon-o-chat-bubble-left-ellipsis'; }

    public function handle(array $formData, array $submitConfig): void
    {
        $name  = trim(($formData['first_name'] ?? '') . ' ' . ($formData['last_name'] ?? ''));
        $email = $formData['email'] ?? 'unknown';

        Http::timeout(5)->post(config('services.slack.webhook_url'), [
            'text' => "New lead: *{$name}* ({$email}) submitted the contact form.",
        ]);
    }
}

CrmWebhookHook

<?php

namespace App\FormHooks;

use Ccast\Tagixo\FormBuilder\Hooks\FormHook;
use Illuminate\Support\Facades\Http;

class CrmWebhookHook extends FormHook
{
    public static function getKey(): string   { return 'crm-webhook'; }
    public static function getLabel(): string { return 'CRM Webhook'; }
    public static function getIcon(): string  { return 'heroicon-o-arrow-up-right'; }

    public function handle(array $formData, array $submitConfig): void
    {
        Http::timeout(10)
            ->withToken(config('services.crm.api_token'))
            ->post(config('services.crm.contacts_endpoint'), [
                'source'     => 'tagixo-form',
                'form_key'   => request()->route('formKey'),
                'first_name' => $formData['first_name'] ?? null,
                'last_name'  => $formData['last_name']  ?? null,
                'email'      => $formData['email']      ?? null,
                'phone'      => $formData['phone']      ?? null,
                'ip'         => request()->ip(),
            ]);
    }
}

Registration

// app/Providers/AppServiceProvider.php

$registry = app(\Ccast\Tagixo\FormBuilder\Hooks\FormHookRegistry::class);
$registry->register(GdprConsentHook::class);     // runs first
$registry->register(SlackNotificationHook::class);
$registry->register(CrmWebhookHook::class);      // runs last

Then in the builder, attach all three to the form's Submit Button in that order.

Testing hooks

Unit testing the hook class

Test handle() in isolation by faking HTTP calls and using an in-memory database:

// tests/Unit/GdprConsentHookTest.php

use App\FormHooks\GdprConsentHook;
use App\Models\UserConsent;

it('records a consent row on handle', function () {
    $formData     = ['email' => 'test@example.com', 'consent' => 'on'];
    $submitConfig = ['action' => 'send-email', 'hooks' => ['gdpr-consent']];

    (new GdprConsentHook)->handle($formData, $submitConfig);

    expect(UserConsent::count())->toBe(1)
        ->and(UserConsent::first()->payload)->toBe($formData);
});

Integration testing: asserting a hook fires on form submit

// tests/Feature/FormHookIntegrationTest.php

use App\Models\UserConsent;
use Illuminate\Support\Facades\Http;

it('fires gdpr consent hook after successful form submission', function () {
    Http::fake(); // prevent real Slack/CRM calls

    $this->post('/forms/contact/submit', [
        'first_name' => 'Maria',
        'last_name'  => 'Bianchi',
        'email'      => 'maria@example.com',
        'consent'    => 'on',
    ])->assertJson(['success' => true]);

    expect(UserConsent::count())->toBe(1);
});

it('does not record consent when the action fails', function () {
    // Submit to a non-existent form key to trigger a 404 before any hook runs
    $this->post('/forms/nonexistent/submit', [])->assertStatus(404);

    expect(UserConsent::count())->toBe(0);
});

Asserting a hook throws without breaking the response

it('reports hook exceptions but still returns a success response', function () {
    $this->instance(
        \App\FormHooks\SlackNotificationHook::class,
        new class extends \Ccast\Tagixo\FormBuilder\Hooks\FormHook {
            public static function getKey(): string   { return 'slack-notification'; }
            public static function getLabel(): string { return 'Slack Notification'; }
            public static function getIcon(): string  { return 'heroicon-o-bolt'; }

            public function handle(array $formData, array $submitConfig): void {
                throw new \RuntimeException('Slack is down');
            }
        }
    );

    $this->post('/forms/contact/submit', ['email' => 'x@example.com'])
        ->assertJson(['success' => true]);
});

Testing with Octane: use RefreshDatabase and avoid patterns that assume session state — Octane recycles the application container across requests. For hook tests specifically, the stateless nature of handle() makes unit tests simpler and more reliable than full HTTP integration tests.

Hooks vs. Actions reference

Action Hook
How many per form Exactly one Any number
Purpose Primary handler (email, redirect, store record) Side-effects (logging, CRM, analytics, consent)
Return value FormActionResult (success, message, redirectUrl) void
Runs if action fails No
Exception blocks response Yes No — caught by report(), next hook continues
Ordering N/A Top-to-bottom as configured in the builder
Async support Depends on action implementation Via queued job dispatched from handle()

See also