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 aftermount()(booted(),updated(), etc.). - Not available: in static methods, in
$resourceresolution, or in Blade templates that are evaluated beforemount()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(),
];
}