Resource Builder Pages
A resource builder page embeds the Tagixo visual builder inside a Filament resource, letting editors build rich content for any Eloquent model (blog posts, landing pages, email templates, etc.) without leaving the admin panel.
Standalone vs Resource-Bound
| Feature | Standalone builder page | Resource builder page |
|---|---|---|
| Lives inside a resource | No | Yes |
| Loads/saves from a record | No — works with a global scope | Yes — $this->record |
| Breadcrumbs | Flat | Resource › Record › Builder |
| Auth | Own gate | Inherits resource policy |
| Multiple per resource | N/A | Yes (e.g. content + mail) |
Use a resource builder page whenever the builder output belongs to a specific model row.
Base Class Declaration
Every resource builder page extends Ccast\TagixoPrimix\Pages\BuilderPage and declares the owning resource via the static $resource property:
namespace App\Filament\Resources\BlogPostResource\Pages;
use App\Filament\Resources\BlogPostResource;
use Ccast\TagixoPrimix\Pages\BuilderPage;
class EditBlogPostContent extends BuilderPage
{
protected static string $resource = BlogPostResource::class;
}
Filament derives the record class, route key, navigation group, and policy from $resource automatically. You do not need to redeclare them.
Artisan Scaffold
php artisan make:filament-page EditBlogPostContent --resource=BlogPostResource --type=custom
Then change the parent class from Page to BuilderPage and add the two required method stubs (loadStructure and saveStructure).
loadStructure()
loadStructure() is called when the builder mounts. Return the raw structure array that the builder should hydrate, or null to start with an empty canvas.
public function loadStructure(): ?array
{
if (! $this->record) {
return null;
}
// If the column is cast to array in the model:
return $this->record->content_structure;
// If the column is stored as raw JSON and not cast:
// return json_decode($this->record->getRawOriginal('content_structure'), true);
}
Cast vs raw column. When the model casts the column to array or json, Eloquent decodes it for you — use the property directly. When the column is not cast, use getRawOriginal() + json_decode() to avoid double-encoding issues.
Null handling. Returning null is safe; the builder renders an empty canvas. Avoid returning an empty array [] — it can confuse the hydration layer into treating it as a malformed structure.
saveStructure()
saveStructure(array $structure) is called when the editor clicks Save. Persist the cleaned structure however your model expects it:
public function saveStructure(array $structure): void
{
$this->record->update([
'content_structure' => $structure,
]);
}
Pre-save hook for render artifacts. If you need to store a rendered HTML snapshot alongside the structure (for preview thumbnails, search indexing, etc.), do it here before or after the update:
public function saveStructure(array $structure): void
{
$html = \Ccast\Tagixo\Services\PageRenderer::renderStructure($structure);
$this->record->update([
'content_structure' => $structure,
'content_html' => $html,
]);
}
Automatic structure cleaning. The SDK runs a cleaning pass on $structure before passing it to saveStructure(), stripping ephemeral editor state (drag handles, selection markers). You receive a clean, serialisable array — do not run your own strip pass on top.
getContext()
The builder context controls which modules, prop types, and CSS generators are available. Return one of the following string constants:
| Value | Use case |
|---|---|
'page' |
Full public page (default) |
'mail' |
Email template — inline styles only, no animations |
'form' |
Form builder canvas |
'partial' |
Reusable content partial, no layout wrapper |
public function getContext(): string
{
return 'page'; // default; safe to omit if building a standard page
}
Wrong context = broken output. Using
'page'for an email builder will inject CSS that email clients strip. Using'mail'for a public page builder disables responsive breakpoints. Always set the context explicitly when it is not a standard page.
Registering in getPages()
Add the builder page to the resource's getPages() array:
public static function getPages(): array
{
return [
'index' => Pages\ListBlogPosts::route('/'),
'create' => Pages\CreateBlogPost::route('/create'),
'edit' => Pages\EditBlogPost::route('/{record}/edit'),
'content' => Pages\EditBlogPostContent::route('/{record}/content'),
];
}
Table action wiring. To add a button in the table row:
// Inside the resource's table() method
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\Action::make('edit_content')
->label('Edit Content')
->icon('heroicon-o-pencil-square')
->url(fn ($record) => static::getUrl('content', ['record' => $record])),
])
Authorization
By default, the builder page checks the update ability on the resource's model. If the user cannot update the record, the page redirects to the index with a Filament notification.
Overriding the ability:
protected static string $permissionAbility = 'editContent';
This requires a matching editContent(User $user, BlogPost $post) method in the BlogPostPolicy.
Hiding from navigation. Resource builder pages do not appear in the sidebar by default ($shouldRegisterNavigation = false is inherited from the base class). To surface one in the nav, override:
protected static bool $shouldRegisterNavigation = true;
protected static ?string $navigationLabel = 'Content Builder';
protected static ?string $navigationIcon = 'heroicon-o-squares-2x2';
Breadcrumbs and getTitle()
The SDK composes breadcrumbs automatically:
Blog Posts › "My Post Title" › Edit Content
The middle segment resolves via the resource's getRecordTitle(). To customise the page title:
public function getTitle(): string
{
return 'Content — ' . $this->record->title;
}
Example output in the breadcrumb: Content — Getting Started with Tagixo.
afterSave() Redirect Behaviour
After saveStructure() returns, the SDK shows a success notification and stays on the builder page (no redirect). To redirect to the edit page instead:
protected function afterSave(): void
{
$this->redirect(static::getResource()::getUrl('edit', ['record' => $this->record]));
}
Multiple Builder Pages per Resource
When a resource needs two separate builders (e.g. public content + transactional email), create two page classes with different getContext() values and register both in getPages():
// Pages/EditBlogPostContent.php
class EditBlogPostContent extends BuilderPage
{
protected static string $resource = BlogPostResource::class;
public function loadStructure(): ?array { return $this->record->content_structure; }
public function saveStructure(array $s): void { $this->record->update(['content_structure' => $s]); }
public function getContext(): string { return 'page'; }
}
// Pages/EditBlogPostMail.php
class EditBlogPostMail extends BuilderPage
{
protected static string $resource = BlogPostResource::class;
public function loadStructure(): ?array { return $this->record->mail_structure; }
public function saveStructure(array $s): void { $this->record->update(['mail_structure' => $s]); }
public function getContext(): string { return 'mail'; }
}
Register both in getPages() under distinct keys ('content' and 'mail').
Complete BlogPost Example
1. Migration
Schema::create('blog_posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('slug')->unique();
$table->json('content_structure')->nullable();
$table->timestamps();
});
2. Model
class BlogPost extends Model
{
protected $fillable = ['title', 'slug', 'content_structure'];
protected $casts = ['content_structure' => 'array'];
}
3. Policy
class BlogPostPolicy
{
public function update(User $user, BlogPost $post): bool
{
return $user->hasRole('editor');
}
}
4. Resource
class BlogPostResource extends Resource
{
protected static ?string $model = BlogPost::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
public static function getPages(): array
{
return [
'index' => Pages\ListBlogPosts::route('/'),
'create' => Pages\CreateBlogPost::route('/create'),
'edit' => Pages\EditBlogPost::route('/{record}/edit'),
'content' => Pages\EditBlogPostContent::route('/{record}/content'),
];
}
public static function table(Table $table): Table
{
return $table
->columns([TextColumn::make('title')])
->actions([
EditAction::make(),
Action::make('content')
->label('Edit Content')
->icon('heroicon-o-squares-2x2')
->url(fn ($record) => static::getUrl('content', ['record' => $record])),
]);
}
}
5. Builder Page
class EditBlogPostContent extends BuilderPage
{
protected static string $resource = BlogPostResource::class;
public function loadStructure(): ?array
{
return $this->record->content_structure;
}
public function saveStructure(array $structure): void
{
$this->record->update(['content_structure' => $structure]);
}
}
6. Standard Pages
php artisan make:filament-page ListBlogPosts --resource=BlogPostResource --type=list
php artisan make:filament-page CreateBlogPost --resource=BlogPostResource --type=create
php artisan make:filament-page EditBlogPost --resource=BlogPostResource --type=edit
7. Asset Publication
php artisan vendor:publish --tag=tagixo-assets --force
Run this after any ccast/tagixo version bump to ensure the builder JS/CSS in public/vendor/tagixo/ is up to date.
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Builder loads empty despite data in DB | Column not cast; loadStructure returns object |
Add 'content_structure' => 'array' to $casts |
| Save succeeds but builder shows stale data | Octane opcache serving old response | Run php artisan octane:reload |
| Builder page not reachable (404) | Route not registered in getPages() |
Add the route key and call php artisan route:clear |
| Wrong CSS in rendered output | getContext() mismatch |
Return the correct context constant |
| Policy blocks all editors | Default ability is update; custom ability not in policy |
Add the ability to the policy class |
See Also
- Standalone Builder Pages — builder outside a resource
- Filament SDK Integration — full SDK setup guide
- Storage Model and Data Shape — structure JSON schema
- Production Checklist — asset publishing and cache clearing