Builder Pages

Resource Builder Pages

Attach a builder page to a Filament resource record and use the artisan scaffold command.

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