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--previewclass. - 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}\">★</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:
- Module appears in the form builder catalog panel.
- Dragging it onto the canvas renders without JS errors.
- Prop changes in the drawer update the canvas in real time.
columnSpanProp()controls are responsive across all three breakpoints.- Saving the form and reopening it restores all prop values correctly.
- Public form page renders the field with correct HTML.
- Submitting a valid value passes validation and triggers the form action.
- Submitting an invalid value (e.g. out-of-range star count) returns the correct validation error.
- The
requiredtoggle is respected in both validation and HTML attribute. getTypeId()does not collide with any other registered module (checkphp artisan tagixo:list-modules).
See also
- Custom Page Modules — building leaf and wrapper modules for the page builder
- Custom Prop Types — defining reusable prop controls
- Props System — full list of built-in prop classes
- Module System and Catalog — how the unified registry works
- Rendering Pipeline — how modules become HTML on the public page
- Scaffold Commands — all
tagixo:make-*generators and their options