Module Development

Custom Form Modules

Build form fields and wrappers with FormModule/FormModuleWrapper and map validation schemas.

Custom Form Modules

The form builder uses two dedicated base classes — FormModule for leaf field nodes and FormModuleWrapper for structural container nodes. Both live in the unified component registry alongside page modules, but they implement a different rendering contract: instead of owning a Blade view, they delegate HTML rendering to a shared partial keyed on field_type. Understanding this contract is the key to building form extensions that work correctly in every surface — builder canvas, preview, and the live public page.

FormModule vs FormModuleWrapper

FormModule FormModuleWrapper
Purpose Leaf field node (input, select, textarea, custom control) Structural container (row, columns, conditional section)
Renders Shared partial via field_type Children recursively via compileChildren()
Schema toSchema() returns flat field descriptor toSchema() returns wrapper descriptor with children key
Use when Adding a new field type or widget Grouping fields in a custom layout or conditional block

Do not extend the page-module base class (Module) for form components — it does not implement the field_type contract and will not render correctly inside forms.

Scaffolding

# Generate a FormModule
php artisan tagixo:make-form-module StarRating

# Generate a FormModuleWrapper
php artisan tagixo:make-form-module-wrapper TwoColumnLayout

# Package mode (places files under src/ with your package namespace)
php artisan tagixo:make-form-module StarRating --package=acme/my-plugin

# Override output path and namespace per-command
php artisan tagixo:make-form-module StarRating \
  --path=app/Form/Modules \
  --namespace="App\\Form\\Modules"

The global output path and namespace can be set in config/tagixo.php:

'form_modules' => [
    'path'      => app_path('Form/Modules'),
    'namespace' => 'App\\Form\\Modules',
],

FormModule: required methods

<?php

namespace App\Form\Modules;

use Ccast\Tagixo\Modules\Form\FormModule;
use Ccast\Tagixo\Props\StringProp;
use Ccast\Tagixo\Props\SelectProp;

class StarRating extends FormModule
{
    /**
     * Stable machine identifier. Never change after first deploy — changing
     * it orphans any form that already uses this module, because stored
     * payloads reference this string.
     */
    public function getTypeId(): string
    {
        return 'star_rating'; // kebab-case, globally unique
    }

    /**
     * Human label shown in the form module catalog.
     */
    public function getLabel(): string
    {
        return 'Star Rating';
    }

    /**
     * The field_type key selects the shared partial used to render this
     * module in all three surfaces. See the field_type contract section.
     */
    public function getFieldType(): string
    {
        return 'custom'; // use 'custom' when no built-in partial fits
    }

    /**
     * Define props visible in the builder drawer.
     * Always include columnSpanProp() to support the responsive grid.
     */
    public function define(): void
    {
        $this->addProp(StringProp::make('label')->label('Label')->default('Rating'));
        $this->addProp(StringProp::make('placeholder')->label('Placeholder'));
        $this->addProp(
            SelectProp::make('max_stars')
                ->label('Max Stars')
                ->options([3 => '3', 5 => '5', 10 => '10'])
                ->default(5)
        );
        $this->columnSpanProp(); // required for responsive grid support
    }
}

Responsive column span

columnSpanProp() adds the standard column-span prop under the hood. The builder exposes a per-breakpoint control with these values:

Breakpoint Tailwind prefix Default span
Mobile (< 640px) sm: full
Tablet (640–1023px) md: 6 of 12
Desktop (≥ 1024px) lg: 6 of 12

Renaming a type ID

If you must rename getTypeId() after deployment, ship a migration that updates the stored type key in every affected form payload row:

DB::table('tgx_forms')->each(function ($form) {
    $payload = json_decode($form->schema, true);
    // walk payload and replace old key with new key
    DB::table('tgx_forms')->where('id', $form->id)
        ->update(['schema' => json_encode($payload)]);
});

The field_type contract

Form modules delegate HTML rendering to a shared partial. The partial is selected by the string returned from getFieldType(). All surfaces — builder canvas, preview iframe, and the live public page — use the same partial, so a module renders consistently everywhere.

Supported field_type values

field_type Renders as
text <input type="text">
email <input type="email">
tel <input type="tel">
number <input type="number">
textarea <textarea>
select <select> (single)
multiselect <select multiple>
checkbox <input type="checkbox">
radio Radio group from options prop
file <input type="file">
hidden <input type="hidden"> (no canvas representation)
custom Raw HTML from renderCustomField()

Rendering states

  • Builder canvas: interactive but non-submittable; the partial wraps fields in a .tgx-form-field--preview class.
  • Preview iframe: same partial, same class; data entry works but submission is blocked.
  • Frontend (live page): the partial renders without the preview class; the form action fires on submission.

Custom rendering

When no built-in partial fits, return 'custom' from getFieldType() and implement renderCustomField():

public function renderCustomField(array $props, string $name, mixed $value): string
{
    $max   = (int) ($props['max_stars'] ?? 5);
    $label = e($props['label'] ?? 'Rating');
    // build and return raw HTML string
    return view('form-modules.star-rating', compact('max', 'label', 'name', 'value'))->render();
}

Validation

The form submission controller (FormSubmissionController) builds validation rules dynamically by walking the form schema and calling extractValidationRules() on each module instance.

Default pipeline

The base class provides sensible defaults based on common props (required, min, max, accept). You only need to override when your module has props that map to non-standard Laravel rules.

Override example

public function extractValidationRules(array $props): array
{
    $rules = parent::extractValidationRules($props); // always call parent

    $max = (int) ($props['max_stars'] ?? 5);
    $rules[] = "integer";
    $rules[] = "min:1";
    $rules[] = "max:{$max}";

    return $rules;
}

Gotcha: omitting parent::extractValidationRules($props) silently drops the required rule even when the end-user checked "Required" in the drawer.

commonValidationProps()

Override commonValidationProps() to declare which of your props feed into validation. The prop keys you return here are stable API — renaming them is a breaking change for stored form payloads:

protected function commonValidationProps(): array
{
    return ['required', 'max_stars'];
}

toSchema()

toSchema() serialises this module into the JSON structure stored in tgx_forms.schema. Downstream integrations (email renderers, form-table integrations, export tools) depend on this shape being stable.

Base implementation

The base class handles type, name, label, field_type, column_span, and all props declared in define(). For most modules you do not need to override it.

Override example

public function toSchema(array $props): array
{
    $schema              = parent::toSchema($props);
    $schema['max_stars'] = (int) ($props['max_stars'] ?? 5);
    return $schema;
}

FormModuleWrapper

Wrappers own no field rendering — they only compile their children into nested schema and render the surrounding structural HTML.

Required methods

<?php

namespace App\Form\Modules;

use Ccast\Tagixo\Modules\Form\FormModuleWrapper;
use Ccast\Tagixo\Props\SelectProp;
use Ccast\Tagixo\Props\ToggleProp;

class TwoColumnLayout extends FormModuleWrapper
{
    public function getTypeId(): string
    {
        return 'two_column_layout';
    }

    public function getLabel(): string
    {
        return 'Two Columns';
    }

    public function define(): void
    {
        $this->addProp(
            SelectProp::make('gap')
                ->label('Column Gap')
                ->options(['sm' => 'Small', 'md' => 'Medium', 'lg' => 'Large'])
                ->default('md')
        );
        $this->addProp(ToggleProp::make('stack_mobile')->label('Stack on Mobile')->default(true));
    }

    /**
     * Declare which module type IDs may be dropped into this wrapper.
     * Return an empty array to allow any child.
     */
    public function getAllowedChildren(): array
    {
        return []; // allow all
    }

    public function toSchema(array $props, array $children = []): array
    {
        return array_merge(parent::toSchema($props), [
            'children' => $this->compileChildren($children),
        ]);
    }
}

Canvas hint

The builder displays wrappers with a dashed border and a "drop fields here" placeholder when empty. This is automatic — no extra configuration is required.

getAllowedChildren() semantics

Return an array of getTypeId() strings to restrict which field types may be dropped in:

public function getAllowedChildren(): array
{
    return ['text', 'email', 'select']; // only these types may be children
}

An empty array means "allow any registered form module or wrapper."

App-target vs universal fields

Some props or rendering behaviours only make sense in a specific builder context (page builder vs email builder vs form builder). Mark props that should be hidden in other contexts with ->appTarget('form'):

$this->addProp(
    ToggleProp::make('show_in_email_receipt')
        ->label('Show in email receipt')
        ->appTarget('form') // hidden when editing inside the email builder
);

When the SDK that defines a context is absent, SDK-specific props degrade gracefully — they are hidden in the drawer and their values are ignored during rendering. For distributable plugins, prefer universal props and avoid hard-coding an SDK target unless the feature is genuinely context-specific.

Registration

Via config

// config/tagixo.php
'form_modules' => [
    'modules' => [
        \App\Form\Modules\StarRating::class,
        \App\Form\Modules\TwoColumnLayout::class,
    ],
],

Via Tagixo facade (service provider)

use Ccast\Tagixo\Facades\Tagixo;

public function boot(): void
{
    Tagixo::registerFormModule(new \App\Form\Modules\StarRating());
    Tagixo::registerFormModule(new \App\Form\Modules\TwoColumnLayout());
}

Deduplication: if the same type ID is registered twice the second registration silently wins. Keep IDs unique across all installed packages.

Form Actions

A Form Action handles what happens after a valid submission. It is separate from the module system but is the natural next step when building a complete form integration.

<?php

namespace App\Form\Actions;

use Ccast\Tagixo\Forms\AbstractFormAction;
use Ccast\Tagixo\Forms\FormActionResult;

class SaveToDatabase extends AbstractFormAction
{
    public function handle(array $data, array $formSchema): FormActionResult
    {
        // persist $data, send notifications, etc.
        return FormActionResult::success('Thank you for your submission.');
    }
}

Register in a service provider:

Tagixo::registerFormAction('save_to_database', \App\Form\Actions\SaveToDatabase::class);

Reactivity

Props that affect rendering in real time (e.g. max_stars changing the number of star inputs visible on the canvas) require reactivity wiring. See Reactivity & Live Preview for the full pattern.

Complete example: Star Rating

<?php

namespace App\Form\Modules;

use Ccast\Tagixo\Modules\Form\FormModule;
use Ccast\Tagixo\Props\StringProp;
use Ccast\Tagixo\Props\SelectProp;
use Ccast\Tagixo\Props\ToggleProp;

class StarRating extends FormModule
{
    public function getTypeId(): string { return 'star_rating'; }
    public function getLabel(): string  { return 'Star Rating'; }
    public function getFieldType(): string { return 'custom'; }

    public function define(): void
    {
        $this->addProp(StringProp::make('label')->label('Label')->default('Rating'));
        $this->addProp(StringProp::make('name')->label('Field Name')->default('rating'));
        $this->addProp(
            SelectProp::make('max_stars')
                ->label('Max Stars')
                ->options([3 => '3', 5 => '5', 10 => '10'])
                ->default(5)
        );
        $this->addProp(ToggleProp::make('required')->label('Required')->default(false));
        $this->columnSpanProp();
    }

    public function extractValidationRules(array $props): array
    {
        $rules = parent::extractValidationRules($props);
        $max   = (int) ($props['max_stars'] ?? 5);
        $rules[] = 'integer';
        $rules[] = 'min:1';
        $rules[] = "max:{$max}";
        return $rules;
    }

    public function toSchema(array $props): array
    {
        return array_merge(parent::toSchema($props), [
            'max_stars' => (int) ($props['max_stars'] ?? 5),
        ]);
    }

    public function renderCustomField(array $props, string $name, mixed $value): string
    {
        $max      = (int) ($props['max_stars'] ?? 5);
        $label    = e($props['label'] ?? 'Rating');
        $required = !empty($props['required']) ? 'required' : '';
        $html     = "<fieldset class=\"tgx-star-rating\" {$required}><legend>{$label}</legend>";
        for ($i = $max; $i >= 1; $i--) {
            $checked = ((int) $value === $i) ? 'checked' : '';
            $html .= "<input type=\"radio\" id=\"{$name}_{$i}\" name=\"{$name}\" value=\"{$i}\" {$checked}>";
            $html .= "<label for=\"{$name}_{$i}\">&#9733;</label>";
        }
        $html .= "</fieldset>";
        return $html;
    }
}

Accompanying CSS (add to your form stylesheet):

/* Row-reverse radio trick for accessible star rating */
.tgx-star-rating {
    display: flex;
    flex-direction: row-reverse;
    justify-content: flex-end;
    border: none;
    padding: 0;
}

.tgx-star-rating input[type="radio"] {
    position: absolute;
    opacity: 0;
    width: 0;
    height: 0;
}

.tgx-star-rating label {
    font-size: 2rem;
    color: #ccc;
    cursor: pointer;
}

.tgx-star-rating input:checked ~ label,
.tgx-star-rating label:hover,
.tgx-star-rating label:hover ~ label {
    color: #f5a623;
}

Registration:

// config/tagixo.php
'form_modules' => [
    'modules' => [\App\Form\Modules\StarRating::class],
],

Complete example: Two-column layout wrapper

<?php

namespace App\Form\Modules;

use Ccast\Tagixo\Modules\Form\FormModuleWrapper;
use Ccast\Tagixo\Props\SelectProp;
use Ccast\Tagixo\Props\ToggleProp;

class TwoColumnLayout extends FormModuleWrapper
{
    public function getTypeId(): string { return 'two_column_layout'; }
    public function getLabel(): string  { return 'Two Columns'; }

    public function define(): void
    {
        $this->addProp(
            SelectProp::make('gap')
                ->label('Column Gap')
                ->options(['gap-2' => 'Small', 'gap-4' => 'Medium', 'gap-8' => 'Large'])
                ->default('gap-4')
        );
        $this->addProp(ToggleProp::make('divider')->label('Show Divider')->default(false));
        $this->addProp(ToggleProp::make('stack_mobile')->label('Stack on Mobile')->default(true));
    }

    public function getAllowedChildren(): array
    {
        return []; // all field types allowed
    }

    public function toSchema(array $props, array $children = []): array
    {
        $schema             = parent::toSchema($props);
        $schema['children'] = $this->compileChildren($children);
        return $schema;
    }
}

Registration:

// config/tagixo.php
'form_modules' => [
    'modules' => [
        \App\Form\Modules\StarRating::class,
        \App\Form\Modules\TwoColumnLayout::class,
    ],
],

Testing checklist

Before shipping a custom form module, verify each of the following:

  1. Module appears in the form builder catalog panel.
  2. Dragging it onto the canvas renders without JS errors.
  3. Prop changes in the drawer update the canvas in real time.
  4. columnSpanProp() controls are responsive across all three breakpoints.
  5. Saving the form and reopening it restores all prop values correctly.
  6. Public form page renders the field with correct HTML.
  7. Submitting a valid value passes validation and triggers the form action.
  8. Submitting an invalid value (e.g. out-of-range star count) returns the correct validation error.
  9. The required toggle is respected in both validation and HTML attribute.
  10. getTypeId() does not collide with any other registered module (check php artisan tagixo:list-modules).

See also