Core Integration

Storage Model and Data Shape

Use the canonical payload shape and define persistence conventions that scale independently.

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 id is missing or parent_id references 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 (h1h6) 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 _styles overrides
  • 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:

  1. Decode the incoming JSON string to a PHP array; reject immediately if malformed.
  2. Validate shape — see Validation rules below.
  3. Clean via CleansBuilderStructure — strips serialization noise before persisting.
  4. Persist the cleaned payload as the canonical content column.
  5. Re-render — call PageRenderer::render($page) to generate rendered_html and rendered_css.
  6. 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() — never get() — 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_pages can 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