Builder Pages

Resource Builder Page

Attach the builder to a Primix resource record using PrimixVisualBuilderPage.

BuilderPage — Visual Builder Page for Primix Resources

When to use

Primix ships two distinct page types for integrating the Tagixo visual builder into an admin resource:

Class Use case
PrimixVisualBuilderPage Attached to a collection resource — each record in the list table has its own builder page. Example: a Page resource where each page record has its own builder content.
BuilderPage A standalone singleton page — no record list, one global structure. Example: a "Hero Banner" global config that has exactly one row in the database.

This document covers PrimixVisualBuilderPage. If you need the singleton variant, see BuilderPage instead.


Extending the class

Scaffold (recommended)

php artisan primix:builder-page {ResourceClass}

This generates a file at app/Primix/Resources/{ResourceClass}/Pages/BuilderPage.php with the correct namespace, $resource property, and stub implementations of loadStructure() and saveStructure().

Manual class structure

namespace App\Primix\Resources\PostResource\Pages;

use Primix\Resources\Pages\PrimixVisualBuilderPage;
use App\Primix\Resources\PostResource;

class BuilderPage extends PrimixVisualBuilderPage
{
    protected static string $resource = PostResource::class;

    protected function loadStructure(): string|array|null
    {
        return $this->record->content;
    }

    protected function saveStructure(string $structure): void
    {
        $this->record->update(['content' => $structure]);
    }
}

The $resource property

protected static string $resource = PostResource::class;

The base class reads $resource to:

  • Resolve the Eloquent model class ($resource::getModel()).
  • Generate route names and breadcrumb URLs.
  • Apply resource-level canEdit() / canView() policy gates.
  • Inherit the resource's global search and navigation registration.

Gotcha — early-load ordering. $resource is a static property resolved at compile time. If you reference it inside a service provider boot() before the resource is registered with the panel, you will get a ClassNotRegisteredException. Always reference it after PanelProvider::boot() has run (i.e., inside request handling, not in provider constructors).


$this->record

$this->record is the Eloquent model instance for the record being edited. It is populated by the base class during mount() — which means:

  • Available: inside loadStructure(), saveStructure(), page action closures, and any Livewire lifecycle hook that runs after mount() (booted(), updated(), etc.).
  • Not available: in static methods, in $resource resolution, or in Blade templates that are evaluated before mount() runs.

Eager-loading relations

The base class loads the record by primary key with no eager loads. If loadStructure() touches a relation, add it in getRecord():

protected function getRecord(int|string $key): Model
{
    return PostResource::getModel()::with(['author', 'tags'])->findOrFail($key);
}

loadStructure()

loadStructure() must return the builder structure in one of three forms:

Return type When to use
string The column already stores a raw JSON string (e.g. TEXT column, no cast).
array The column has an Eloquent array or AsArrayObject cast — Laravel has already decoded it.
null No structure yet; the builder opens with an empty canvas.

Pattern 1 — array cast (most common)

// migration: $table->json('content')->nullable();
// model:     protected $casts = ['content' => 'array'];

protected function loadStructure(): string|array|null
{
    return $this->record->content; // returns array|null
}

Pattern 2 — raw JSON string

// model: no cast on 'content'

protected function loadStructure(): string|array|null
{
    return $this->record->getRawOriginal('content'); // returns string|null
}

Pattern 3 — repository / value object

protected function loadStructure(): string|array|null
{
    return $this->record->contentRepository()->toArray();
}

Warning — do not double-encode. If your column has an array cast, Eloquent returns a PHP array. Returning it directly is correct — the base class serialises it. Calling json_encode() on it yourself before returning produces a double-encoded string and the builder will fail to parse the structure.


saveStructure()

saveStructure(string $structure) receives the builder payload as a JSON string. It is called after the user clicks "Save" inside the builder canvas.

Basic update

protected function saveStructure(string $structure): void
{
    $this->record->update(['content' => $structure]);
}

If the column has an array cast, Eloquent will call json_encode() internally — passing the string is still correct.

Queued side effect

If saving triggers a slow side effect (regenerating a static cache, sending a webhook), dispatch a job rather than blocking the request:

protected function saveStructure(string $structure): void
{
    $this->record->update(['content' => $structure]);

    RegeneratePageCacheJob::dispatch($this->record->id);
}

getPages() registration

Register the builder page inside the resource's getPages() method alongside the standard index, create, and edit pages:

// app/Primix/Resources/PostResource.php

public static function getPages(): array
{
    return [
        'index'   => Pages\ListPosts::route('/'),
        'create'  => Pages\CreatePost::route('/create'),
        'edit'    => Pages\EditPost::route('/{record}/edit'),
        'builder' => Pages\BuilderPage::route('/{record}/builder'),
    ];
}

Route naming convention

The array key becomes the last segment of the route name: primix.posts.builder. Use a descriptive key (builder, content, email-template) — avoid generic names like page2 that break navigation link generation.

Navigation link from EditRecord

Add a link to the builder page in the edit form's header actions so editors can jump between metadata and canvas:

// app/Primix/Resources/PostResource/Pages/EditPost.php

protected function getHeaderActions(): array
{
    return [
        Action::make('open_builder')
            ->label('Edit Content')
            ->url(fn () => PostResource::getUrl('builder', ['record' => $this->record]))
            ->icon('heroicon-o-paint-brush'),

        Actions\DeleteAction::make(),
    ];
}

Link from the list table

Add an action column to ListPosts so editors can jump directly to the builder from the index:

Tables\Actions\Action::make('builder')
    ->label('Builder')
    ->icon('heroicon-o-paint-brush')
    ->url(fn (Post $record) => PostResource::getUrl('builder', ['record' => $record])),

Breadcrumbs and title

The base class auto-derives a breadcrumb chain:

Dashboard > Posts > {record title} > Builder

The record title is taken from getRecordTitle() on the resource. By default it calls $record->getKey() (the primary key). Override it in the resource to show a human-readable label:

// app/Primix/Resources/PostResource.php

public static function getRecordTitle(Model $record): string
{
    return $record->title ?? "Post #{$record->id}";
}

Overriding the page title

Override getTitle() on the builder page to customise the <h1> and <title> tag:

public function getTitle(): string
{
    return "Content Editor — {$this->record->title}";
}

Overriding breadcrumbs

Override getBreadcrumbs() to return a custom ordered array of [url => label] pairs:

public function getBreadcrumbs(): array
{
    return [
        PostResource::getUrl()                                           => 'Posts',
        PostResource::getUrl('edit', ['record' => $this->record])       => $this->record->title,
        PostResource::getUrl('builder', ['record' => $this->record])    => 'Content',
    ];
}

excludedCanvasPropTypes()

Some prop types are only meaningful in certain contexts. Override excludedCanvasPropTypes() on the builder page to hide irrelevant design controls from the sidebar:

protected function excludedCanvasPropTypes(): array
{
    return [
        \Ccast\Tagixo\PropTypes\AnimationPropType::class,
        \Ccast\Tagixo\PropTypes\StickyPropType::class,
    ];
}

Recommended exclusions by resource type

Resource type Suggested exclusions
Email template AnimationPropType, StickyPropType, ParallaxPropType, VideoBackgroundPropType
Landing page None — all prop types apply
Notification / short content StickyPropType, ParallaxPropType
PDF export target AnimationPropType, VideoBackgroundPropType, StickyPropType

Multi-builder-page resources

A resource can register more than one builder page — for example, a "Content" canvas and a separate "Email Template" canvas for the same record:

// app/Primix/Resources/PostResource.php

public static function getPages(): array
{
    return [
        'index'          => Pages\ListPosts::route('/'),
        'create'         => Pages\CreatePost::route('/create'),
        'edit'           => Pages\EditPost::route('/{record}/edit'),
        'builder'        => Pages\ContentBuilderPage::route('/{record}/builder'),
        'email-template' => Pages\EmailTemplateBuilderPage::route('/{record}/email-template'),
    ];
}

Each builder page class has its own loadStructure() / saveStructure() that reads and writes a different column:

// Pages/ContentBuilderPage.php
protected function loadStructure(): string|array|null
{
    return $this->record->content; // json column 'content'
}

protected function saveStructure(string $structure): void
{
    $this->record->update(['content' => $structure]);
}

// Pages/EmailTemplateBuilderPage.php
protected function loadStructure(): string|array|null
{
    return $this->record->email_template; // json column 'email_template'
}

protected function saveStructure(string $structure): void
{
    $this->record->update(['email_template' => $structure]);
}

Column isolation warning. Each builder page must read and write an isolated column. Never share the same column between two builder pages on the same resource — the second save will silently overwrite the first.


End-to-end example — BlogPost

1. Migration

Schema::create('blog_posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('slug')->unique();
    $table->json('content')->nullable();
    $table->timestamps();
});

2. Model

// app/Models/BlogPost.php

class BlogPost extends Model
{
    protected $fillable = ['title', 'slug', 'content'];

    protected $casts = [
        'content' => 'array',
    ];
}

3. Resource

// app/Primix/Resources/BlogPostResource.php

class BlogPostResource extends Resource
{
    protected static ?string $model = BlogPost::class;
    protected static ?string $navigationIcon = 'heroicon-o-document-text';

    public static function getRecordTitle(Model $record): string
    {
        return $record->title;
    }

    public static function getPages(): array
    {
        return [
            'index'   => Pages\ListBlogPosts::route('/'),
            'create'  => Pages\CreateBlogPost::route('/create'),
            'edit'    => Pages\EditBlogPost::route('/{record}/edit'),
            'builder' => Pages\BuilderPage::route('/{record}/builder'),
        ];
    }
}

4. Builder page

// app/Primix/Resources/BlogPostResource/Pages/BuilderPage.php

namespace App\Primix\Resources\BlogPostResource\Pages;

use Primix\Resources\Pages\PrimixVisualBuilderPage;
use App\Primix\Resources\BlogPostResource;

class BuilderPage extends PrimixVisualBuilderPage
{
    protected static string $resource = BlogPostResource::class;

    protected function loadStructure(): string|array|null
    {
        return $this->record->content;
    }

    protected function saveStructure(string $structure): void
    {
        $this->record->update(['content' => $structure]);
    }
}

5. Link from EditRecord

// app/Primix/Resources/BlogPostResource/Pages/EditBlogPost.php

protected function getHeaderActions(): array
{
    return [
        Action::make('open_builder')
            ->label('Edit Content')
            ->url(fn () => BlogPostResource::getUrl('builder', ['record' => $this->record]))
            ->icon('heroicon-o-paint-brush'),

        Actions\DeleteAction::make(),
    ];
}