Module Development

Module System and Catalog

Understand built-in module categories, context filtering, layout hierarchy, and the registry internals.

Module System and Catalog

The module system is the backbone of the Tagixo visual builder. Every draggable block — headings, images, forms, sliders — is a module. This document covers how modules are discovered, registered, structured, rendered, and deprecated.


What Is a Module?

A module is a PHP class that:

  1. Declares an identity (type ID, label, icon, category).
  2. Defines a schema — the props the user can edit in the sidebar drawer.
  3. Optionally declares sub-elements (child slots or repeatable items).
  4. Provides a Blade view that turns resolved props into HTML.

At boot, every registered module is serialised into the bootstrap payload that the builder's Vue app receives. At render time, the PHP side resolves stored JSON props through the same schema and hands them to the Blade view.


Built-in Catalog

Page / General Modules (context: page)

Type ID Label Notes
heading Heading H1–H6, font, size, weight, colour
text Text Rich-text Tiptap block
image Image Responsive image with alt, link, lazy
button Button Link, variant, icon, hover state
divider Divider Horizontal rule with gap/colour
spacer Spacer Fixed or responsive empty space
html HTML Raw HTML injection
icon Icon Single Blade Icons glyph
icon-list Icon List Repeatable icon + text rows
video Video YouTube / Vimeo embed or <video>
slider Slider Multi-slide carousel with transitions
map Map Embedded Google / OpenStreetMap
menu Menu Navigation menu bound to a menu tree
global-block Global Block Reference to a tgx_global_blocks record

Layout Modules (context: page)

Type ID Label Notes
section Section Full-width row; contains columns
column Column Flex / grid column inside a section
container Container Max-width wrapper

Form Field Modules (context: form)

Type ID Label
form-text Text Input
form-textarea Textarea
form-select Select / Dropdown
form-checkbox Single Checkbox
form-checkbox-group Checkbox Group
form-radio Radio Group
form-file File Upload
form-hidden Hidden Field
form-date Date / DateTime
form-range Range Slider

Form Wrapper Modules (context: form)

Type ID Label Notes
form-group Field Group Visual grouping, no semantics
form-columns Columns Side-by-side field layout
form-step Step One page of a multi-step form
form-wizard Wizard Contains form-step children

Module Registration

Via config/tagixo.php

// config/tagixo.php
return [
    'modules' => [
        App\Tagixo\Modules\TestimonialModule::class,
        App\Tagixo\Modules\PricingTableModule::class,
    ],
];

Tagixo merges this array with its own built-ins at boot. Order in the config does not affect picker order — the category and sort value determine that.

Via Tagixo::registerModule() in a service provider

use Ccast\Tagixo\Facades\Tagixo;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Tagixo::registerModule(App\Tagixo\Modules\TestimonialModule::class);
    }
}

register() vs boot() gotcha: call registerModule() in boot(), not register(). The Tagixo service provider must have already run (it registers the registry singleton), which is guaranteed only during boot.


Module Class Structure

namespace App\Tagixo\Modules;

use Ccast\Tagixo\Modules\AbstractModule;
use Ccast\Tagixo\PropTypes\TextPropType;
use Ccast\Tagixo\PropTypes\ImagePropType;
use Ccast\Tagixo\PropTypes\SelectPropType;

class TestimonialModule extends AbstractModule
{
    // ── Identity ──────────────────────────────────────────────────────────

    public function typeId(): string
    {
        return 'testimonial';           // must be globally unique; never rename
    }

    public function label(): string
    {
        return 'Testimonial';
    }

    public function icon(): string
    {
        return 'heroicon-o-chat-bubble-left-ellipsis';
    }

    public function category(): string
    {
        return 'content';               // built-in or custom category key
    }

    // ── Schema ────────────────────────────────────────────────────────────

    public function define(): void
    {
        $this
            ->content([
                TextPropType::make('quote')->label('Quote')->multiline(),
                TextPropType::make('author')->label('Author'),
                ImagePropType::make('avatar')->label('Avatar'),
            ])
            ->design([
                // design props (colours, spacing, etc.) go here
            ])
            ->subElements([
                // named sub-elements — see Sub-Elements & States doc
            ])
            ->defaults([
                'quote'  => 'Add your testimonial here.',
                'author' => 'Jane Doe',
            ])
            ->contexts(['page']);        // restrict to page builder
    }

    // ── View (optional override) ──────────────────────────────────────────

    public function view(): string
    {
        return 'modules.testimonial';   // resources/views/modules/testimonial.blade.php
    }
}

Required overrides

Method Return type Purpose
typeId() string Stable string key stored in JSON
label() string Picker and drawer title
define() void Declares the full schema

Optional overrides (with defaults)

Method Default When to override
icon() 'heroicon-o-puzzle-piece' Give the module a recognisable picker icon
category() 'general' Group with similar modules
view() Auto-derived from typeId() Custom view path outside vendor/
allowsChildren() false Set true for layout/container modules
getAllowedChildren() [] (any) Whitelist child type IDs when allowsChildren() is true
contexts() ['page'] Limit to specific builder contexts
sort() 100 Lower numbers appear earlier in the picker

Context Filtering

A module can declare which builder contexts it is valid in:

->contexts(['page', 'mail'])    // visible in page builder and mail builder
->contexts(['form'])            // visible only in the form builder
->contexts(['page'])            // default — page builder only

When ->contexts() is omitted, the module defaults to ['page'].

Available contexts

Key Builder
page Page / layout / global-block builder
form Form builder
mail Email template builder
pdf PDF template builder

At boot, each context's module list is serialised separately into the bootstrap payload. The picker filters its catalog client-side using the active context from the payload — no extra HTTP request is made when the user switches contexts.

Inside a Blade view, the current context is available as $context so a module can conditionally render features that are not available in every pipeline (e.g. interactive JS is not injected in the mail pipeline):

@if ($context === 'page')
    <script>/* ... */</script>
@endif

Layout Hierarchy and Child Constraints

allowsChildren()

When true, the builder's drop zones inside this module are activated. Layout modules (section, column, container) return true. Content modules should return false (the default).

getAllowedChildren()

Returns an array of allowed child type IDs, or an empty array to allow any:

public function getAllowedChildren(): array
{
    return ['column'];   // only columns may be dropped into a section
}

The builder enforces the whitelist at drag time (the drop zone rejects forbidden types) and at render time (forbidden children are silently skipped). If you have existing JSON that contains disallowed children, they will disappear from the canvas after saving — audit your content when tightening constraints.

Nesting depth: The builder does not enforce a maximum nesting depth, but deeply nested structures (section → column → container → column → section) can cause z-index and overflow issues. The recommended maximum is four levels.


Module Categories

Built-in categories, in default picker order:

Key Label Sort
layout Layout 10
content Content 20
media Media 30
form Form 40
interactive Interactive 50
general General 100

Register a custom category from a service provider:

use Ccast\Tagixo\Facades\Tagixo;

Tagixo::registerCategory('marketing', label: 'Marketing', sort: 25);

Modules whose category() returns an unregistered key are placed in General automatically.


Component Registry Internals

At boot, Tagixo's ComponentRegistry processes every registered module class and stores:

Field Source
typeId typeId()
label label()
icon icon()
category category()
contexts contexts() (or default ['page'])
sort sort()
allowsChildren allowsChildren()
allowedChildren getAllowedChildren()
schema Result of define() — prop tree
defaults Merged from ->defaults() + prop ->default() values
viewPath Resolved Blade view path

Resolution order at render time

When the renderer encounters a module type ID in the stored JSON, it:

  1. Looks up the entry in ComponentRegistry by typeId.
  2. Merges stored props with schema defaults (stored values win).
  3. Passes resolved props through each PropType's resolve() method (e.g. converts a media ID to a URL).
  4. Renders the resolved props through the Blade view.

Inspection API

use Ccast\Tagixo\Facades\Tagixo;

// All registered modules
$all = Tagixo::registry()->all();

// One module by type ID
$entry = Tagixo::registry()->get('testimonial');

// Check if a type ID is registered
$exists = Tagixo::registry()->has('testimonial');

Module Lifecycle

1. Schema serialisation (boot)

define() is called once per request cycle (or once per Octane worker lifetime). The resulting schema tree is serialised to JSON and included in the builder bootstrap payload at GET /tagixo/builder/bootstrap.

2. Builder interaction (client)

The Vue app reads the bootstrap payload. When the user drags a module from the picker, a new node is inserted into the page tree with the module's defaults as its initial props. Prop edits in the sidebar drawer update the in-memory tree reactively.

3. Save (JSON stored in database)

When the user saves, the tree is POSTed as a JSON blob and stored in tgx_pages.content (or the equivalent column for layouts, global blocks, etc.). Each node looks like:

{
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "type": "testimonial",
    "props": {
        "quote": "Tagixo made our site 10x faster to build.",
        "author": "Jane Doe"
    },
    "children": [],
    "_styles": {}
}

4. Props resolution (render)

PageRenderer walks the tree. For each node it calls ComponentRegistry::resolve($node), which merges defaults, runs PropType::resolve() on every prop value, and returns a flat associative array.

5. CSS generation (StyleGenerator)

_styles is handed to StyleGenerator, which outputs scoped CSS rules targeting the node's ID. These rules are injected into a <style> block in the page <head>.

6. HTML output

The resolved props array and generated CSS class names are passed to the Blade view. The view is responsible only for semantic markup; all positioning and spacing come from the style block.

The module wrapper div is emitted by the renderer, not the view:

<div id="tgx-node-550e8400" class="tgx-module tgx-module--testimonial" data-tgx-type="testimonial">
    <!-- Blade view output here -->
</div>

Do not emit the outer wrapper from inside your Blade view — it will be doubled.


Type ID Stability

The typeId() return value is stored verbatim in every page's JSON. Never rename a type ID — doing so orphans all existing content that uses that module. The renderer will skip nodes whose type ID is not in the registry, so renamed modules silently disappear from live pages.

If a rename is unavoidable, run a SQL migration before deploying:

-- pages
UPDATE tgx_pages
SET content = REPLACE(content::text, '"type":"old-id"', '"type":"new-id"')::jsonb
WHERE content::text LIKE '%"type":"old-id"%';

-- layouts
UPDATE tgx_layouts
SET content = REPLACE(content::text, '"type":"old-id"', '"type":"new-id"')::jsonb
WHERE content::text LIKE '%"type":"old-id"%';

The same rule applies to prop keys and sub-element keys — renaming them drops stored values silently.


Deprecation

Option 1: Hide from picker (soft deprecation)

Override contexts() to return an empty array. Existing content continues to render; the module simply disappears from the picker:

public function contexts(): array
{
    return [];   // hidden from every context's picker
}

Option 2: Alias (type ID migration over time)

use Ccast\Tagixo\Facades\Tagixo;

// In a service provider boot():
Tagixo::aliasModule('old-testimonial', 'testimonial');

When the renderer encounters old-testimonial it resolves to the testimonial module. The alias is transparent — the user sees and edits the new module. On the next save, the type ID is written as testimonial, so aliases self-heal over time.

Caveat: aliases are one-hop only. Tagixo::aliasModule('a', 'b') + Tagixo::aliasModule('b', 'c') does not chain — a resolves to b, not c.

Option 3: Hard removal

Before removing a module class entirely, audit residual references:

SELECT id, slug
FROM tgx_pages
WHERE content::text LIKE '%"type":"my-module"%';

Migrate or delete those records, then remove the class from config/tagixo.php and deploy.