Module Development

Custom Page Modules

Step-by-step implementation for a custom content module used in page, mail, and document contexts.

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.