Storage Model and Data Shape
The Tagixo builder serializes page content as a single JSON document stored in the content column of a tgx_pages (or equivalent) record. Understanding this shape is the prerequisite for writing migrations, building importers, validating payloads programmatically, or reasoning about how render artifacts are produced. Everything the renderer does is a deterministic transform of this JSON — no hidden state, no side channels.
Top-level structure
The root object has two keys: body and components.
{
"body": {
"background": { "colorEnabled": true, "color": "#ffffff" },
"spacing": {
"padding": { "top": "60", "right": "0", "bottom": "60", "left": "0", "unit": "px" }
}
},
"components": [ /* flat array of all nodes */ ]
}
| Key | Type | Required | Purpose |
|---|---|---|---|
body |
object | yes | Canvas-level visual props (background, spacing, custom class). Equivalent to the props of an implicit root node. Not a component node — has no id, type, or parent_id. Rendered by PageRenderer as the outermost wrapper styles. |
components |
array | yes | Flat list of every structural and content node in the page. The tree is reconstructed at render time by grouping on parent_id and sorting by order. Flat storage keeps diffs, versioning, and reordering cheap. |
Node contract
Every entry in components must satisfy the following contract:
{
"id": "sec_abc123",
"type": "section",
"parent_id": null,
"order": 0,
"props": {},
"_styles": {}
}
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | Globally unique within the payload. Stable across saves — never regenerate unless the node is deleted and re-created. Prefix conventions: sec_ (section), row_ (row), col_ (column), mod_ (leaf module). Generated client-side at creation time. |
type |
string | yes | Registered module key — must match an entry in the module catalog (section, row, column, heading, text, image, etc.). Treat as an immutable identifier; changing it after content exists requires a content migration. |
parent_id |
string | null | yes | null for root-level sections. Points to the id of the direct parent for all other nodes. Every non-null value must reference a node that exists in the same components array. |
order |
integer | yes | Zero-based position among siblings sharing the same parent_id. Must be contiguous and unique within the sibling group. Reorder by rewriting the entire sibling sequence, not by editing individual values. |
props |
object | yes | Content fields — text, URLs, toggle states, structural switches. Never contains CSS. Always present; may be {}. |
_styles |
object | no | Visual / CSS fields keyed by PropType group name. Absent or {} when no styles are set. |
label |
string | no | Editor-only display name; never rendered. |
hidden |
boolean | no | When true the node is excluded from rendered output. |
locked |
boolean | no | When true the editor prevents drag-drop moves on this node. |
Reject malformed nodes early. If
idis missing orparent_idreferences a non-existent node, the renderer will produce broken output silently. Validate before persisting, not inside the renderer.
Module hierarchy
The canonical nesting order is section → row → column → module. Sections and rows are structural containers. Columns carry their own _styles for width distribution. Modules are the leaf nodes that hold real content.
Section
{
"id": "sec_abc123",
"type": "section",
"parent_id": null,
"order": 0,
"props": {
"htmlTag": "section",
"customClass": "",
"anchorId": ""
},
"_styles": {
"background": {
"default": { "colorEnabled": true, "color": "#f8f8f8" },
"_responsive": { "mobile": { "colorEnabled": false } }
},
"spacing": {
"default": {
"padding": { "top": "80", "right": "0", "bottom": "80", "left": "0", "unit": "px" }
},
"_responsive": {
"mobile": {
"padding": { "top": "40", "right": "0", "bottom": "40", "left": "0", "unit": "px" }
}
}
}
}
}
Section props are structural hints (HTML tag name, CSS class, anchor fragment). Visual treatment lives entirely in _styles.
Row
{
"id": "row_def456",
"type": "row",
"parent_id": "sec_abc123",
"order": 0,
"props": {
"maxWidth": { "value": "1200", "unit": "px" },
"equalHeight": false,
"reverseOnMobile": false
},
"_styles": {
"spacing": {
"default": {
"gap": { "value": "24", "unit": "px" }
}
}
}
}
Rows hold columns and control horizontal layout. maxWidth constrains the inner content width; gap controls column spacing.
Column
{
"id": "col_ghi789",
"type": "column",
"parent_id": "row_def456",
"order": 0,
"props": {
"width": { "value": "50", "unit": "%" }
},
"_styles": {
"background": {
"default": { "colorEnabled": false }
},
"spacing": {
"default": {
"padding": { "top": "0", "right": "24", "bottom": "0", "left": "24", "unit": "px" }
}
}
}
}
props.width drives the flex-basis for the column within its row. When all siblings sum to 100%, the row fills horizontally. Responsive overrides go into _styles._responsive.
Module (leaf)
{
"id": "mod_jkl012",
"type": "heading",
"parent_id": "col_ghi789",
"order": 0,
"props": {
"content": {
"text": "Welcome to Tagixo",
"tag": "h1"
},
"link": {
"href": "",
"target": "_self"
}
},
"_styles": {
"typography": {
"default": {
"fontFamily": "Inter, sans-serif",
"fontSize": { "value": "48", "unit": "px" },
"fontWeight": "700",
"lineHeight": { "value": "1.1", "unit": "" },
"color": "#111111",
"textAlign": "left"
},
"_responsive": {
"mobile": {
"fontSize": { "value": "28", "unit": "px" }
}
}
},
"spacing": {
"default": {
"margin": { "top": "0", "right": "0", "bottom": "16", "left": "0", "unit": "px" }
}
}
}
}
Module props contains everything that is content: text strings, image URLs, link hrefs, booleans that switch functionality on/off. Module _styles contains everything that is visual: typography, colors, spacing, borders, shadows.
The _styles object in depth
_styles is a two-level map: the outer keys are PropType group names; the inner keys are state keys representing when each style applies.
_styles: {
<propTypeGroupKey>: {
"default": { <prop>: <value>, … },
"_responsive": {
"tablet": { <prop>: <value>, … },
"mobile": { <prop>: <value>, … }
},
"_hover": { <prop>: <value>, … },
"_sticky": { <prop>: <value>, … }
}
}
State key reference
| State key | When applied |
|---|---|
default |
Base styles — always applied. |
_responsive.tablet |
Applied when viewport matches the tablet breakpoint. |
_responsive.mobile |
Applied when viewport matches the mobile breakpoint. |
_hover |
Applied on :hover of the element. |
_sticky |
Applied when a sticky section crosses its threshold and is in the stuck position. |
Styles cascade in specificity order (highest wins): _sticky > _hover > _responsive.mobile > _responsive.tablet > default.
Only keys that differ from the base need to be present under responsive/hover/sticky. The renderer merges states — it does not replace the entire object. An explicit null value under _responsive or _hover means "reset to browser default" for that property — it is intentionally preserved during cleaning (see Automatic structure cleaning).
PropType group name reference
| Group key | Controls |
|---|---|
background |
Background color, gradient, image, overlay |
typography |
Font family, size, weight, line height, letter spacing, color, alignment, decoration |
spacing |
Padding and margin (with per-side values and unit) |
border |
Border width, style, color, per-corner radius |
box_shadow |
Shadow offset, blur, spread, color, inset flag |
filter |
CSS filters: blur, brightness, contrast, grayscale, opacity |
transform |
Translate, rotate, scale |
animation |
Entry animation name, duration, delay, easing |
sizing |
Width, height, min/max constraints and their units |
Custom PropTypes registered by your app or third-party modules use whatever key they declare in their key() method.
Props vs _styles: the hard rule
props = content. _styles = visual.
This is not a soft convention — it determines how the builder's data layer and the renderer treat the data.
| Category | Goes in props |
Goes in _styles |
|---|---|---|
| Rich text / plain text body | yes | — |
| Image URL or media ID | yes | — |
| Link href and target | yes | — |
Toggle to enable a feature (e.g. sticky, fullWidth) |
yes | — |
HTML tag override (h1–h6) |
yes (semantic) | — |
| Font size, weight, line height | — | yes |
| Text color | — | yes |
| Background color, gradient, or image | — | yes |
| Padding and margin | — | yes |
| Border width, style, color, radius | — | yes |
| Responsive overrides of any visual value | — | yes (via _responsive) |
| Hover and sticky state styles | — | yes (via _hover / _sticky) |
Modules that combine content and display (e.g. a stat counter with both a number and a color) keep the number in props and the color in _styles. The renderer reads from both independently: props feed the Blade/component template; _styles feed the CSS generator.
Repeater items
Modules with repeating children (carousels, icon lists, tabs, testimonial grids) store their items in props as an array. Each item is a plain object matching the module's declared item schema.
{
"id": "mod_repeater01",
"type": "icon_list",
"parent_id": "col_ghi789",
"order": 1,
"props": {
"items": [
{
"_id": "item_a1b2",
"icon": "heroicon-check-circle",
"label": "Fast builds"
},
{
"_id": "item_c3d4",
"icon": "heroicon-check-circle",
"label": "Zero config"
}
]
},
"_styles": {
"typography": {
"default": { "fontSize": { "value": "16", "unit": "px" }, "color": "#333333" }
}
}
}
_id stability requirement
_id on repeater items is the stable identity of that item across saves. It is used to:
- Match per-item
_stylesoverrides - Anchor drag-drop reordering in the editor
- Identify deleted items in diff-based saves
Do not regenerate _id values when re-serializing, importing, or migrating content. Regenerating them silently drops per-item style overrides and breaks undo history in the editor. Generate once at item creation time, then preserve the value forever.
Per-item _styles override pattern
When a module supports per-item visual overrides, _styles is stored inside each item object alongside the content fields. The renderer merges the module-level _styles with the per-item _styles, with per-item taking precedence.
{
"_id": "item_a1b2",
"icon": "heroicon-check-circle",
"label": "Fast builds",
"_styles": {
"typography": {
"default": { "color": "#22c55e" }
}
}
}
Global settings — tgx_pages column reference
The tgx_pages table stores additional metadata alongside the JSON content:
| Column | Type | Purpose |
|---|---|---|
id |
bigint PK | Internal record identifier |
title |
string | Page title; used in <title> and the admin list |
slug |
string, unique | URL path segment; used by PublicPageController |
layout_id |
bigint FK, nullable | FK to tgx_layouts; overrides the global default layout |
content |
JSON, nullable | The full builder payload (body + components) |
custom_css |
text, nullable | Raw CSS injected after generated styles — escape hatch only |
rendered_html |
longtext, nullable | Pre-rendered HTML artifact; served at runtime without re-rendering |
rendered_css |
longtext, nullable | Pre-rendered CSS artifact for the page's component tree |
status |
enum | draft or published |
published_at |
timestamp, nullable | When the page was last published |
meta_description |
text, nullable | SEO meta description |
created_at / updated_at |
timestamps | Standard Laravel timestamps |
custom_css vs _styles
custom_css is the escape hatch for styles that PropTypes cannot express: third-party widget overrides, print rules, very specific selector chains. It is appended verbatim after all generated CSS and is not parsed or validated.
_styles is the PropType-managed system. Prefer _styles so that responsive and state variants work correctly in the editor preview. Do not use custom_css to replicate what _styles can already express — it creates divergence between editor preview and live output.
Save strategy
When the editor submits a save, the pipeline follows these six steps:
- Decode the incoming JSON string to a PHP array; reject immediately if malformed.
- Validate shape — see Validation rules below.
- Clean via
CleansBuilderStructure— strips serialization noise before persisting. - Persist the cleaned payload as the canonical
contentcolumn. - Re-render — call
PageRenderer::render($page)to generaterendered_htmlandrendered_css. - Persist the render artifacts; invalidate any upstream cache entries.
Steps 5–6 happen synchronously on save by default. For high-traffic sites with large pages, step 5 can be deferred to a queued job:
$page->update(['content' => $cleanedPayload, 'rendered_html' => null, 'rendered_css' => null]);
RenderPageJob::dispatch($page->id);
PublicPageController detects a null rendered_html and falls back to a single live render for the next request, caching the result. Never render at request time from raw content on every request — rendering is CPU-intensive.
Validation rules you should enforce
Before persisting any payload, assert at minimum:
use Illuminate\Support\Facades\Validator;
$v = Validator::make($payload, [
'body' => ['required', 'array'],
'components' => ['required', 'array'],
'components.*.id' => ['required', 'string', 'distinct'],
'components.*.type' => ['required', 'string'],
'components.*.parent_id' => ['present', 'nullable', 'string'],
'components.*.order' => ['required', 'integer', 'min:0'],
'components.*.props' => ['required', 'array'],
]);
if ($v->fails()) {
throw new \InvalidArgumentException('Malformed builder payload: ' . $v->errors()->toJson());
}
Additional structural integrity checks worth adding:
| Check | What it enforces |
|---|---|
| Known type | type must match a key registered in the module catalog |
| Referential integrity | Every non-null parent_id must reference an id in the same components array |
| Cycle detection | The parent_id chain must be acyclic (no node may be its own ancestor) |
| Hierarchy depth | Row must have a section parent; column a row parent; leaf modules a column parent |
| Sibling order uniqueness | Within a parent_id group, order values must be unique and contiguous |
Do not rely on the renderer to detect and repair structural violations. A renderer that silently skips orphaned nodes will produce pages that look correct but have lost content.
Automatic structure cleaning
Before a save is committed, the trait CleansBuilderStructure (included automatically via InteractsWithVisualBuilder) strips serialization noise from the payload. You do not need to call this manually on the standard save path.
What gets stripped
| Pattern | Example | Reason |
|---|---|---|
| Empty string values | "fontFamily": "" |
PropType default before the field is touched |
| Unit-only size objects | { "unit": "px" } |
Normalizer emits this when no numeric value was set |
| Unit-only spacing objects | { "top": "", "right": "", "unit": "px" } |
Same pattern across all TRBL spacing fields |
| Unused heading level stubs | { "h3": {}, "h4": {} } |
Heading module adds stubs for all levels unconditionally |
What gets preserved
| Pattern | Reason |
|---|---|
"parent_id": null |
Structural field; null is meaningful and must be explicit |
null inside _responsive / _hover / _sticky |
Intentional editor reset — "use browser default at this breakpoint" |
| Empty arrays on declared sequence fields | Structural; an empty components array is valid for a blank page |
If you implement a custom save path that does not use InteractsWithVisualBuilder, call cleanStructure() manually before persisting:
use Ccast\Tagixo\Concerns\CleansBuilderStructure;
class MyPageSaver
{
use CleansBuilderStructure;
public function save(array $payload): array
{
return $this->cleanStructure($payload);
}
}
Without cleaning, every save accumulates a growing volume of empty-string and unit-only noise. After many edits, payload size can grow 3–5x with no meaningful content increase, and diffs between revisions become unreadable.
Content migrations
When a plugin release changes the shape of a PropType — field renames, key restructuring, removed defaults — existing stored content must be migrated. Do not rely on the renderer to silently handle old shapes; that leads to invisible data loss on the next save.
Migration pattern
// database/migrations/2025_01_01_000000_migrate_typography_color_key.php
use Illuminate\Database\Migrations\Migration;
use Ccast\Tagixo\Models\Page;
return new class extends Migration
{
public function up(): void
{
// lazyById() avoids loading thousands of records into memory at once
Page::query()->whereNotNull('content')->lazyById(100)->each(function (Page $page) {
$content = $page->content;
$dirty = false;
foreach ($content['components'] as &$node) {
// Example: rename _styles.typography.default.textColor → color
if (isset($node['_styles']['typography']['default']['textColor'])) {
$node['_styles']['typography']['default']['color'] =
$node['_styles']['typography']['default']['textColor'];
unset($node['_styles']['typography']['default']['textColor']);
$dirty = true;
}
}
unset($node);
if (! $dirty) {
return;
}
// updateQuietly() skips model event listeners — no recursive saves,
// no search index triggers, no unrelated cache invalidations.
$page->updateQuietly([
'content' => $content,
'rendered_html' => null, // mark for re-render
'rendered_css' => null,
]);
// Optional: re-render immediately rather than deferring to next request
// app(\Ccast\Tagixo\Services\PageRenderer::class)->renderAndPersist($page);
});
}
public function down(): void
{
Page::query()->whereNotNull('content')->lazyById(100)->each(function (Page $page) {
$content = $page->content;
$dirty = false;
foreach ($content['components'] as &$node) {
if (isset($node['_styles']['typography']['default']['color'])) {
$node['_styles']['typography']['default']['textColor'] =
$node['_styles']['typography']['default']['color'];
unset($node['_styles']['typography']['default']['color']);
$dirty = true;
}
}
unset($node);
if ($dirty) {
$page->updateQuietly(['content' => $content, 'rendered_html' => null, 'rendered_css' => null]);
}
});
}
};
Guidelines for content migrations
- Use
lazyById()— neverget()— to avoid loading thousands of records into memory. updateQuietly()skips model event listeners; call the renderer explicitly if you need to re-render immediately.- Always write
down(). A migration without rollback is a data trap. - Never perform content migrations inside request-time rendering. The renderer must be idempotent and read-only with respect to
content. - Test against a production data snapshot before deploying. A broken migration on
tgx_pagescan take the entire public site offline.
Freeze the plugin version while a migration is running. Upgrading the plugin mid-migration can introduce a second shape change and corrupt records that were partially migrated.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Saving only rendered_html without content |
Page cannot be re-edited or re-rendered after initial publish | Always persist content; treat rendered_html as a derived cache |
| Storing children as nested arrays inside each node | Flat-tree consumers (migrations, importers, the renderer) produce wrong output | Always store components as a flat top-level array keyed by parent_id + order |
Regenerating node id values on re-serialization or import |
CSS scoping breaks; editor selection loses its anchor; style overrides vanish | Generate id once at creation time, then preserve it across every subsequent save |
Changing a module's registered type key after content exists |
Renderer cannot resolve the type; those nodes silently drop from rendered output | Treat type keys as immutable identifiers; write a content migration if a rename is unavoidable |
Reordering nodes by editing individual order values without rewriting siblings |
Gaps and duplicates in order produce non-deterministic render order |
Rewrite the entire sibling sequence when inserting, removing, or reordering |
| Running content migrations inside a request or render lifecycle | Unpredictable latency; migration failures cause 500s under load | Migrations belong in one-shot Artisan commands or database migration files, never in middleware or controllers |
| Persisting PropType noise without cleaning | Payload grows unbounded; diffs across versions are impossible to read | Always run CleansBuilderStructure before persisting |
Using custom_css to reproduce what _styles already covers |
Editor preview diverges from live output; responsive variants stop working in the builder | Move the style into the appropriate _styles PropType group |
See also
- Database and Models —
tgx_pages,tgx_layouts,tgx_menus, andtgx_formstable schemas in full. - Rendering and Preview Workflow — how
PageRenderertransforms the payload into HTML and CSS. - PropTypes System — how
_stylesgroups are declared, registered, and resolved during rendering. - Module System and Catalog — registering custom modules with their own
propsand_stylesschemas. - Custom Props — defining content fields (
props) for custom modules. - Sub-elements and States — how
_hover,_sticky, andsubElementswork within a module's_styles.