Custom Page Modules
Custom modules are the primary extension point for adding domain-specific content blocks to the builder. Each module is a PHP class that declares its content schema, design capabilities, and sub-element structure, paired with a Blade view that turns a stored payload into HTML. This page walks the full implementation sequence using a TestimonialCard module — a realistic example that exercises content props, design PropTypes, repeatable sub-elements, and context control.
Step 0 — Scaffold the module
The artisan command generates a pre-wired PHP class and Blade view so you never start from a blank file.
php artisan make:tagixo-module TestimonialCard
By default the class lands in app/Tagixo/Modules/ and the view in resources/views/tagixo/modules/. Override either with flags:
php artisan make:tagixo-module TestimonialCard \
--class-path=app/Domain/Modules \
--view-path=resources/views/blocks
When building a distributable plugin, pass --package to emit files relative to the package root instead of the host application:
php artisan make:tagixo-module TestimonialCard --package=my-vendor/my-plugin
The generated class extends Ccast\Tagixo\Modules\Module and includes empty stubs for every method the builder calls.
Step 1 — Module class anatomy
namespace App\Tagixo\Modules;
use Ccast\Tagixo\Modules\Module;
class TestimonialCard extends Module
{
public function getTypeId(): string { return 'testimonial_card'; }
public function getLabel(): string { return 'Testimonial Card'; }
public function getIcon(): string { return 'heroicon-o-chat-bubble-left-ellipsis'; }
public function getCategory(): string { return 'social-proof'; }
}
| Method | Contract |
|---|---|
getTypeId() |
Snake-case string, globally unique. Stored in page payload — never change it after content has been saved. |
getLabel() |
Human-readable name shown in the module catalog. Translatable via __(). |
getIcon() |
Any Blade Icons identifier (heroicon-o-*, tabler-*, etc.). Rendered in the catalog and the element tree. |
getCategory() |
Slug that groups modules in the catalog sidebar. Register custom categories in config if the built-ins do not fit. |
Type-ID immutability: getTypeId() is the primary key Tagixo uses to re-hydrate saved content. Renaming it after pages have been built silently breaks those pages. If a rename is unavoidable, provide a migration that rewrites the type-id in every affected tgx_pages.content JSON column.
Step 2 — Content props
Content props define the editable fields that appear in the builder's Content drawer. Declare them by overriding contentProps():
use Ccast\Tagixo\Props\EditorProp;
use Ccast\Tagixo\Props\TextProp;
use Ccast\Tagixo\Props\MediaProp;
use Ccast\Tagixo\Props\SelectProp;
use Ccast\Tagixo\Props\ToggleProp;
public function contentProps(): array
{
return [
TextProp::make('quote')
->label('Quote')
->placeholder('The product changed everything for us.')
->required(),
TextProp::make('author_name')
->label('Author name')
->default('Jane Smith'),
TextProp::make('author_role')
->label('Author role')
->placeholder('CEO, Acme Corp'),
MediaProp::make('avatar')
->label('Author photo')
->accept(['image/*']),
SelectProp::make('star_rating')
->label('Stars')
->options([1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5'])
->default(5),
ToggleProp::make('show_stars')
->label('Show star rating')
->default(true)
->showWhen('star_rating', '!=', null),
];
}
Built-in Prop classes
| Class | Stored as | Notes |
|---|---|---|
TextProp |
string |
Single line; use EditorProp for rich text |
EditorProp |
Tiptap JSON | Renders via {!! $module->renderRichText($fieldName) !!} |
MediaProp |
Media ID + metadata array | Resolved to URL via $module->mediaUrl($fieldName) |
SelectProp |
scalar (string/int) | Supports ->searchable() for long option lists |
ToggleProp |
bool |
Renders as a toggle switch in the drawer |
NumberProp |
int|float |
Supports ->min(), ->max(), ->step() |
ColorProp |
hex/rgba string | Exposes the Tagixo colour picker |
LinkProp |
link-object array | Internal slug, external URL, open_popup, mailto, tel |
RepeaterProp |
array of objects | Each item can contain any of the above props |
CodeProp |
string |
Monaco editor; language hint via ->language('css') |
Fluent methods available on every Prop
| Method | Effect |
|---|---|
->label(string) |
Label shown above the field in the drawer |
->default(mixed) |
Value used when no content has been saved yet |
->placeholder(string) |
Placeholder text for text-like inputs |
->required() |
Marks the field required (validated before save) |
->helperText(string) |
Small hint rendered below the field — do not use {{ }} in the string; Vue evaluates mustaches and crashes if the variable is undefined |
->showWhen(field, op, value) |
Hides/shows the field based on another field's current value |
Conditional fields (showWhen)
showWhen is evaluated client-side for UI responsiveness, but your Blade view must not rely on it for security — the hidden field's value is still submitted and stored. Always check the controlling flag in PHP before rendering the dependent output:
// in the Blade view — do not assume show_stars=false means star_rating is absent
@if($props['show_stars'] && isset($props['star_rating']))
<div class="stars">...</div>
@endif
Step 3 — Design PropTypes
Design PropTypes map to the builder's Design tab and generate scoped CSS automatically. Declare them by overriding designProps():
use Ccast\Tagixo\PropTypes\TypographyPropType;
use Ccast\Tagixo\PropTypes\BackgroundPropType;
use Ccast\Tagixo\PropTypes\BorderPropType;
use Ccast\Tagixo\PropTypes\SpacingPropType;
use Ccast\Tagixo\PropTypes\ShadowPropType;
use Ccast\Tagixo\PropTypes\SizingPropType;
public function designProps(): array
{
return [
TypographyPropType::make('quote_text')->label('Quote typography'),
TypographyPropType::make('author_name_text')->label('Author name typography'),
BackgroundPropType::make('card')->label('Card background'),
BorderPropType::make('card_border')->label('Card border'),
SpacingPropType::make('card_spacing')->label('Card spacing'),
ShadowPropType::make('card_shadow')->label('Card shadow'),
SizingPropType::make('card_size')->label('Card size'),
];
}
Each PropType exposes a panel in the Design drawer. The StyleGenerator picks up every declared PropType and emits scoped CSS targeting the element's unique scope ID — you do not write any CSS by hand for these controls.
Built-in PropType catalog
Design tab
| PropType | CSS it controls |
|---|---|
TypographyPropType |
font-family, font-size, font-weight, line-height, letter-spacing, text-align, color, text-transform |
BackgroundPropType |
background-color, background-image, gradient, overlay opacity |
BorderPropType |
border-width, border-style, border-color, border-radius |
SpacingPropType |
padding, margin (per side, responsive) |
ShadowPropType |
box-shadow |
SizingPropType |
width, max-width, height, min-height |
Advanced tab
| PropType | CSS it controls |
|---|---|
FilterPropType |
filter (blur, brightness, contrast, etc.) |
TransformPropType |
transform (rotate, scale, translate) |
AnimationPropType |
CSS keyframe animation (entrance, scroll-driven) |
Setting defaults with DefaultsBuilder
use Ccast\Tagixo\PropTypes\Support\DefaultsBuilder;
public function designDefaults(): array
{
return DefaultsBuilder::make()
->background('card', ['colorEnabled' => true, 'color' => '#ffffff'])
->spacing('card_spacing', ['paddingTop' => '24px', 'paddingBottom' => '24px',
'paddingLeft' => '32px', 'paddingRight' => '32px'])
->border('card_border', ['radius' => '12px'])
->shadow('card_shadow', ['preset' => 'md'])
->build();
}
Step 4 — Sub-elements
Sub-elements let each named part of a module carry its own design PropTypes and states independently. Declare them in subElements():
use Ccast\Tagixo\Modules\SubElement;
public function subElements(): array
{
return [
SubElement::structural('quote')
->label('Quote text')
->propTypes([
TypographyPropType::make('text'),
]),
SubElement::structural('author_name')
->label('Author name')
->propTypes([
TypographyPropType::make('text'),
]),
SubElement::structural('author_role')
->label('Author role')
->propTypes([
TypographyPropType::make('text'),
]),
SubElement::structural('avatar')
->label('Avatar')
->propTypes([
SizingPropType::make('size'),
BorderPropType::make('shape'),
]),
SubElement::structural('stars')
->label('Star rating')
->propTypes([
ColorPropType::make('color')->label('Star colour'),
SizingPropType::make('size'),
]),
];
}
Each sub-element key ('quote', 'author_name', etc.) must match the data-vb-element attribute you add to the corresponding DOM node in the Blade view (see Step 5). The StyleGenerator scopes CSS to that attribute.
Repeatable sub-elements: if a sub-element can appear multiple times (e.g. a list of testimonials inside a slider), use SubElement::repeatable() instead of SubElement::structural(). The builder will render per-item style panels and generate data-item-index attributes automatically.
Key stability: sub-element keys are stored in the page payload alongside their styles. Renaming a key after content has been saved discards those styles silently. Treat sub-element keys as immutable once published — add new ones freely, but do not rename existing ones.
Single-sub-element repeaters: if a repeatable sub-element contains exactly one child, suppress the Elements panel in the builder by returning true from hideSingleSubElementPanel() on the SubElement.
Step 5 — Blade view
The scaffold creates resources/views/tagixo/modules/testimonial-card.blade.php. The builder and the public renderer both call it with the same set of variables:
| Variable | Type | Contains |
|---|---|---|
$props |
array |
Resolved content prop values (keys match contentProps() names) |
$styles |
StyleBag |
Pre-compiled CSS; write it with {!! $styles->tag() !!} |
$scopeId |
string |
Unique ID for CSS scoping; apply to the root element as data-vb-scope |
$element |
ModuleElement |
The live element object; rarely needed in the view |
$context |
string |
Active render context: 'page', 'mail', 'pdf', etc. |
<div data-vb-scope="{{ $scopeId }}" class="testimonial-card">
{!! $styles->tag() !!}
<blockquote data-vb-element="quote" class="testimonial-card__quote">
{{ $props['quote'] ?? '' }}
</blockquote>
<div class="testimonial-card__author">
@if(!empty($props['avatar']))
<img
data-vb-element="avatar"
src="{{ $module->mediaUrl('avatar') }}"
alt="{{ $props['author_name'] ?? '' }}"
class="testimonial-card__avatar"
/>
@endif
<div class="testimonial-card__author-info">
<span data-vb-element="author_name" class="testimonial-card__name">
{{ $props['author_name'] ?? '' }}
</span>
@if(!empty($props['author_role']))
<span data-vb-element="author_role" class="testimonial-card__role">
{{ $props['author_role'] ?? '' }}
</span>
@endif
</div>
</div>
@if(($props['show_stars'] ?? false) && !empty($props['star_rating']))
<div data-vb-element="stars" class="testimonial-card__stars" aria-label="{{ $props['star_rating'] }} out of 5 stars">
@for($i = 1; $i <= 5; $i++)
<svg class="testimonial-card__star {{ $i <= $props['star_rating'] ? 'is-filled' : '' }}"
...></svg>
@endfor
</div>
@endif
</div>
What the view owns vs what the renderer owns
| Responsibility | Who handles it |
|---|---|
| HTML structure | Blade view |
| Scoped design CSS (typography, background, border, etc.) | $styles->tag() — do not duplicate |
| Content prop values | $props array |
| Media URLs | $module->mediaUrl('fieldName') |
| Rich-text output | {!! $module->renderRichText('fieldName') !!} |
Font <link> tags |
PageRenderer — do not emit in the view |
| Wrapper layout (section, column) | Parent element — the view renders only its own root |
Escaping discipline
| Field type | Correct output syntax |
|---|---|
Plain text (TextProp) |
{{ $props['field'] }} — auto-escaped |
Rich text (EditorProp) |
{!! $module->renderRichText('field') !!} — pre-sanitised HTML |
| Media URL | {{ $module->mediaUrl('field') }} inside src= — auto-escaped |
| Raw HTML you build yourself | {!! e($value) !!} or avoid entirely |
Empty-state pattern
The builder calls the view even when no content has been saved yet. Guard every prop access with ?? '' or ?? false, and render a visible placeholder so the element remains selectable in the canvas:
@if(empty($props['quote']))
<p class="testimonial-card__placeholder">Add a testimonial quote in the Content drawer.</p>
@else
<blockquote ...>{{ $props['quote'] }}</blockquote>
@endif
Step 6 — Context registration
Modules must be registered before Tagixo discovers them. Add the class to the modules array in config/tagixo.php:
'modules' => [
\App\Tagixo\Modules\TestimonialCard::class,
],
Context keys
Tagixo supports multiple rendering contexts. Control which contexts your module appears in by overriding allowedContexts():
public function allowedContexts(): array
{
return ['page']; // omit 'mail' and 'pdf' if the module is page-only
}
| Context key | When used | Constraints |
|---|---|---|
'page' |
Standard public page renderer | Full CSS, media, JavaScript |
'mail' |
Email render pipeline | Inline CSS only; no JavaScript, no position:fixed/sticky, no backdrop filters |
'pdf' |
PDF export | Static CSS; no web fonts beyond system stacks; no @media queries |
'preview' |
Admin live preview | Same as 'page'; auth-gated |
If you omit allowedContexts() the module defaults to ['page']. Modules that work correctly in mail must use only inline-safe CSS and avoid media-queried layouts — the mail renderer inlines $styles->tag() automatically, but it cannot inline CSS declared outside that tag.
Step 7 — Label, icon, and category
These three methods control catalog presentation:
// Translatable label
public function getLabel(): string
{
return __('tagixo-modules::testimonial.label');
}
// Any Blade Icons identifier
public function getIcon(): string
{
return 'heroicon-o-chat-bubble-left-ellipsis';
}
// Groups modules in the catalog sidebar
// Built-in slugs: 'text', 'media', 'layout', 'social-proof', 'interactive', 'e-commerce'
// Custom slugs: register via config('tagixo.module_categories')
public function getCategory(): string
{
return 'social-proof';
}
Register a custom category in config/tagixo.php if none of the built-in slugs fit:
'module_categories' => [
'case-studies' => ['label' => 'Case Studies', 'icon' => 'heroicon-o-document-text'],
],
Step 8 — CSS generation
You never write CSS for design PropType outputs — StyleGenerator handles it. To verify what gets generated, call it directly in Tinker:
$page = \Ccast\Tagixo\Models\Page::where('slug', 'home')->firstOrFail();
$element = $page->content->findElementByType('testimonial_card');
$css = \Ccast\Tagixo\Rendering\StyleGenerator::forElement($element)->generate();
echo $css;
Output is scoped to the element's unique scope ID:
[data-vb-scope="el_01j..."] {
background-color: #ffffff;
padding: 24px 32px;
border-radius: 12px;
box-shadow: 0 4px 16px rgb(0 0 0 / .08);
}
[data-vb-scope="el_01j..."] [data-vb-element="quote"] {
font-size: 1.125rem;
line-height: 1.6;
color: #1a1a2e;
}
In DevTools, inspect the root element's data-vb-scope attribute, then search the page <style> blocks for that value to confirm the expected rules are present.
Custom CSS: if you need CSS that StyleGenerator cannot produce (e.g. a ::before pseudo-element or a custom animation), add a <style> block inside the Blade view. Prefix every selector with [data-vb-scope="{{ $scopeId }}"] to keep it scoped, and keep it outside $styles->tag() so the renderer does not attempt to inline it.
Step 9 — Preview image
The catalog card shows a static screenshot of the module. Provide one by overriding getPreviewImageUrl():
public function getPreviewImageUrl(): ?string
{
return asset('vendor/my-plugin/testimonial-card-preview.png');
}
Convention: store preview images at public/vendor/{package-name}/ and publish them via a service provider:
$this->publishes([
__DIR__.'/../resources/previews' => public_path('vendor/my-plugin'),
], 'my-plugin-assets');
Recommended dimensions: 480 × 320 px, PNG, transparent or white background. If getPreviewImageUrl() returns null, the catalog shows the module icon instead.
Complete example
PHP class
<?php
namespace App\Tagixo\Modules;
use Ccast\Tagixo\Modules\Module;
use Ccast\Tagixo\Modules\SubElement;
use Ccast\Tagixo\Props\TextProp;
use Ccast\Tagixo\Props\MediaProp;
use Ccast\Tagixo\Props\SelectProp;
use Ccast\Tagixo\Props\ToggleProp;
use Ccast\Tagixo\PropTypes\TypographyPropType;
use Ccast\Tagixo\PropTypes\BackgroundPropType;
use Ccast\Tagixo\PropTypes\BorderPropType;
use Ccast\Tagixo\PropTypes\SpacingPropType;
use Ccast\Tagixo\PropTypes\ShadowPropType;
use Ccast\Tagixo\PropTypes\SizingPropType;
use Ccast\Tagixo\PropTypes\ColorPropType;
use Ccast\Tagixo\PropTypes\Support\DefaultsBuilder;
class TestimonialCard extends Module
{
public function getTypeId(): string { return 'testimonial_card'; }
public function getLabel(): string { return 'Testimonial Card'; }
public function getIcon(): string { return 'heroicon-o-chat-bubble-left-ellipsis'; }
public function getCategory(): string { return 'social-proof'; }
public function allowedContexts(): array
{
return ['page', 'preview'];
}
public function contentProps(): array
{
return [
TextProp::make('quote')
->label('Quote')
->placeholder('The product changed everything for us.')
->required(),
TextProp::make('author_name')
->label('Author name')
->default('Jane Smith'),
TextProp::make('author_role')
->label('Author role')
->placeholder('CEO, Acme Corp'),
MediaProp::make('avatar')
->label('Author photo')
->accept(['image/*']),
SelectProp::make('star_rating')
->label('Stars')
->options([1 => '1', 2 => '2', 3 => '3', 4 => '4', 5 => '5'])
->default(5),
ToggleProp::make('show_stars')
->label('Show star rating')
->default(true),
];
}
public function designProps(): array
{
return [
TypographyPropType::make('quote_text')->label('Quote typography'),
TypographyPropType::make('author_name_text')->label('Author name typography'),
TypographyPropType::make('author_role_text')->label('Role typography'),
BackgroundPropType::make('card')->label('Card background'),
BorderPropType::make('card_border')->label('Card border'),
SpacingPropType::make('card_spacing')->label('Card spacing'),
ShadowPropType::make('card_shadow')->label('Card shadow'),
SizingPropType::make('card_size')->label('Card size'),
];
}
public function designDefaults(): array
{
return DefaultsBuilder::make()
->background('card', ['colorEnabled' => true, 'color' => '#ffffff'])
->spacing('card_spacing', [
'paddingTop' => '24px',
'paddingBottom' => '24px',
'paddingLeft' => '32px',
'paddingRight' => '32px',
])
->border('card_border', ['radius' => '12px'])
->shadow('card_shadow', ['preset' => 'md'])
->build();
}
public function subElements(): array
{
return [
SubElement::structural('quote')
->label('Quote text')
->propTypes([TypographyPropType::make('text')]),
SubElement::structural('author_name')
->label('Author name')
->propTypes([TypographyPropType::make('text')]),
SubElement::structural('author_role')
->label('Author role')
->propTypes([TypographyPropType::make('text')]),
SubElement::structural('avatar')
->label('Avatar')
->propTypes([
SizingPropType::make('size'),
BorderPropType::make('shape'),
]),
SubElement::structural('stars')
->label('Star rating')
->propTypes([
ColorPropType::make('color')->label('Star colour'),
SizingPropType::make('size'),
]),
];
}
public function getPreviewImageUrl(): ?string
{
return asset('vendor/tagixo-modules/testimonial-card-preview.png');
}
}
Blade view (resources/views/tagixo/modules/testimonial-card.blade.php)
<div data-vb-scope="{{ $scopeId }}" class="testimonial-card">
{!! $styles->tag() !!}
@if(empty($props['quote']))
<p class="testimonial-card__placeholder">Add a quote in the Content drawer.</p>
@else
<blockquote data-vb-element="quote" class="testimonial-card__quote">
{{ $props['quote'] }}
</blockquote>
@endif
<div class="testimonial-card__author">
@if(!empty($props['avatar']))
<img
data-vb-element="avatar"
src="{{ $module->mediaUrl('avatar') }}"
alt="{{ $props['author_name'] ?? '' }}"
class="testimonial-card__avatar"
loading="lazy"
/>
@endif
<div class="testimonial-card__author-info">
@if(!empty($props['author_name']))
<span data-vb-element="author_name" class="testimonial-card__name">
{{ $props['author_name'] }}
</span>
@endif
@if(!empty($props['author_role']))
<span data-vb-element="author_role" class="testimonial-card__role">
{{ $props['author_role'] }}
</span>
@endif
</div>
</div>
@if(($props['show_stars'] ?? false) && !empty($props['star_rating']))
<div
data-vb-element="stars"
class="testimonial-card__stars"
aria-label="{{ $props['star_rating'] }} out of 5 stars"
>
@for($i = 1; $i <= 5; $i++)
<svg
class="testimonial-card__star {{ $i <= (int)$props['star_rating'] ? 'is-filled' : '' }}"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
width="20"
height="20"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
@endfor
</div>
@endif
</div>
Config registration (config/tagixo.php)
'modules' => [
// built-in modules...
\App\Tagixo\Modules\TestimonialCard::class,
],
'module_categories' => [
'social-proof' => [
'label' => 'Social Proof',
'icon' => 'heroicon-o-chat-bubble-left-ellipsis',
],
],
After registering the class, clear the config cache and run php artisan tagixo:discover-modules (if your version requires it) to make the module appear in the builder catalog.