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:
- Declares an identity (type ID, label, icon, category).
- Defines a schema — the props the user can edit in the sidebar drawer.
- Optionally declares sub-elements (child slots or repeatable items).
- 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:
- Looks up the entry in
ComponentRegistrybytypeId. - Merges stored props with schema defaults (stored values win).
- Passes resolved props through each
PropType'sresolve()method (e.g. converts a media ID to a URL). - 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.