Builder Pages

Builder Pages

Extend BuilderPage to wire the visual builder into a Filament page — implement loadStructure, saveStructure, and getContext.

Builder Pages (Filament SDK)

Builder pages integrate the Tagixo visual builder into a Filament admin panel as a standalone page or as part of a resource. This document covers everything you need to implement one correctly.


Standalone vs. Resource Builder Pages

Standalone (BuilderPage) Resource (BuilderAction / inline)
Entity type Singleton — one editable record (e.g. "Home page", "Site settings") Collection — many records managed by a Resource
Navigation entry Yes, registers its own nav item Delegated to the parent resource
URL /admin/my-page /admin/my-resource/{record}/edit
When to use There is exactly one instance of this content Users can create/list/delete multiple instances
Class to extend Ccast\TagixoFilament\Pages\BuilderPage Use BuilderAction in an EditRecord page

Use a standalone BuilderPage when the content is a singleton and does not need a list view. If you find yourself hardcoding a record ID to retrieve the "one" record of a collection, that is a signal to use standalone.


BuilderPage Abstract Class

BuilderPage extends Filament's Page and wires together:

  • Loading the persisted builder payload from your model
  • Rendering the builder Vue application inside the page view
  • Saving the builder payload back to your model on form submit
  • Lifecycle hooks (beforeSave, afterSave)

It does not handle:

  • Database migrations or model creation
  • Authorization beyond the canAccess() / authorize() hooks described below
  • Asset publication (see Asset publication)

Three Required Methods

Every BuilderPage subclass must implement these three methods.

getRecord(): Model

protected function getRecord(): Model

Return the Eloquent model instance that holds the builder payload. The method is called once during mount() and the result is stored on $this->record. Throw a ModelNotFoundException (or any exception) if the record does not exist and the page should be inaccessible.

Gotcha: do not cache the record across requests. Because Livewire serialises the component between requests, return a fresh query result each time.

getBuilderPayload(): ?string

protected function getBuilderPayload(): ?string

Return the raw JSON string currently persisted on the model, or null if no payload has been saved yet. Return null — not an empty string — to signal "start with a blank canvas". An empty string "" is treated as invalid JSON and will throw a parse error in the builder JavaScript.

protected function getBuilderPayload(): ?string
{
    return $this->record->builder_data ?: null;
}

saveBuilderPayload(string $payload): void

protected function saveBuilderPayload(string $payload): void

Persist the JSON string produced by the builder. The $payload argument is already validated JSON and has already been cleaned by the builder (removed ephemeral UI state). You do not need to json_decode / json_encode it again — write it directly.

protected function saveBuilderPayload(string $payload): void
{
    $this->record->builder_data = $payload;
    $this->record->save();
}

Return type is void, not bool. Communicate failures by throwing exceptions; the base class wraps the call in a try/catch and surfaces a Filament notification on error.


Artisan Scaffold

Generate a builder page stub with:

php artisan tagixo-filament:make-builder-page {name} {--model=} {--panel=}
Option Default Description
name (required) Class name, e.g. HomePageBuilder
--model Inferred from name Eloquent model to bind, e.g. HomePage
--panel Default panel Panel ID to register the page into

Generated stub (illustrative — actual output may vary by version):

<?php

namespace App\Filament\Pages;

use App\Models\HomePage;
use Ccast\TagixoFilament\Pages\BuilderPage;
use Illuminate\Database\Eloquent\Model;

class HomePageBuilder extends BuilderPage
{
    protected static ?string $navigationLabel = 'Home Page';

    protected static ?string $navigationIcon = 'heroicon-o-home';

    protected function getRecord(): Model
    {
        return HomePage::firstOrFail();
    }

    protected function getBuilderPayload(): ?string
    {
        return $this->record->builder_data ?: null;
    }

    protected function saveBuilderPayload(string $payload): void
    {
        $this->record->builder_data = $payload;
        $this->record->save();
    }
}

Fill in $navigationLabel, $navigationIcon, and the method bodies before using the stub in production.


Page Registration

Register the page in your panel provider's pages() array:

use App\Filament\Pages\HomePageBuilder;

public function panel(Panel $panel): Panel
{
    return $panel
        // ...
        ->pages([
            HomePageBuilder::class,
        ]);
}

Pages listed here are automatically discovered for navigation. If you use auto-discovery (->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')), you do not need to list the class explicitly — but being explicit is recommended for builder pages to make the registration obvious.


Authorization

canAccess() — navigation and page guard

public static function canAccess(): bool

Filament calls this static method to decide whether to show the nav item and whether to allow access to the page URL. Override it to gate on a Filament permission or a Gate check:

public static function canAccess(): bool
{
    return auth()->user()?->can('manage-home-page') ?? false;
}

authorize() — Livewire action guard

protected function authorize(string $ability, mixed $arguments = []): void

Filament calls authorize('update', $this->record) before processing the save action. Override when your policy uses non-standard ability names:

protected function authorize(string $ability, mixed $arguments = []): void
{
    // Map 'update' → custom ability
    $this->authorizeAccess();
}

Why both? canAccess() is checked on every page load (nav render + HTTP gate). authorize() is checked on every Livewire action (form submit). A user whose role changes mid-session could bypass canAccess() via a direct Livewire call if authorize() is not also hardened. Always override both if your page is sensitive.


Navigation

Control navigation appearance with static properties:

protected static ?string $navigationLabel    = 'Home Page';
protected static ?string $navigationIcon     = 'heroicon-o-home';
protected static ?string $navigationGroup    = 'Content';
protected static ?int    $navigationSort     = 1;
protected static ?string $slug               = 'home-page-builder'; // URL segment

To suppress the page from navigation entirely (e.g. it is opened programmatically):

protected static bool $shouldRegisterNavigation = false;

You can also override the navigation item dynamically:

public static function getNavigationItems(): array
{
    if (! static::canAccess()) {
        return [];
    }

    return parent::getNavigationItems();
}

Complete Working Example

Migration and Model

// database/migrations/2024_01_01_000000_create_home_pages_table.php
Schema::create('home_pages', function (Blueprint $table) {
    $table->id();
    $table->longText('builder_data')->nullable();
    $table->timestamps();
});
// app/Models/HomePage.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class HomePage extends Model
{
    protected $fillable = ['builder_data'];
}

Builder Page

// app/Filament/Pages/HomePageBuilder.php
namespace App\Filament\Pages;

use App\Models\HomePage;
use Ccast\TagixoFilament\Pages\BuilderPage;
use Illuminate\Database\Eloquent\Model;

class HomePageBuilder extends BuilderPage
{
    protected static ?string $navigationLabel = 'Home Page';
    protected static ?string $navigationIcon  = 'heroicon-o-home';
    protected static ?string $navigationGroup = 'Content';
    protected static ?int    $navigationSort  = 10;

    /**
     * mount() ordering gotcha: $this->record is set by the parent mount()
     * via getRecord(). Do NOT call getRecord() yourself before calling
     * parent::mount() — the parent sets $this->record first, then runs
     * fillForm(), which calls getBuilderPayload(). If you need to do work
     * after the record is loaded, use afterMount() or override mount() and
     * call parent::mount() at the top.
     */
    public function mount(): void
    {
        parent::mount(); // sets $this->record, fills the builder form

        // Safe to access $this->record here
        $this->getSubHeading(); // example of post-mount work
    }

    protected function getRecord(): Model
    {
        // firstOrCreate ensures the page always exists
        return HomePage::firstOrCreate([], []);
    }

    protected function getBuilderPayload(): ?string
    {
        return $this->record->builder_data ?: null;
    }

    protected function saveBuilderPayload(string $payload): void
    {
        $this->record->update(['builder_data' => $payload]);
    }

    public static function canAccess(): bool
    {
        return auth()->user()?->hasRole('admin') ?? false;
    }
}

Panel Provider Registration

// app/Providers/AdminPanelProvider.php
use App\Filament\Pages\HomePageBuilder;

public function panel(Panel $panel): Panel
{
    return $panel
        ->id('admin')
        ->path('admin')
        ->pages([
            HomePageBuilder::class,
        ]);
}

Asset Publication

Builder pages require the Tagixo JavaScript and CSS assets to be published. Publish from the core package (ccast/tagixo), not from the Filament integration package:

php artisan vendor:publish --tag=tagixo-assets --force

Publishing from tagixo-filament (if such a tag exists) only publishes Filament-specific views. The builder canvas assets always live in the core package.

Run this command whenever you upgrade ccast/tagixo and commit the updated files in public/vendor/tagixo/ to version control. The production Dockerfile runs composer install --no-scripts, so assets must be pre-built and committed.


Extension Contract Summary

All overridable members of BuilderPage, with signatures:

Member Type Required Description
getRecord(): Model method Yes Returns the model instance to bind
getBuilderPayload(): ?string method Yes Returns persisted JSON or null
saveBuilderPayload(string $payload): void method Yes Persists the JSON string
canAccess(): bool static method No Gate for nav + page access
authorize(string $ability, mixed $arguments): void method No Gate for Livewire actions
beforeSave(): void method No Hook called before payload is saved
afterSave(): void method No Hook called after payload is saved
getTitle(): string method No Page heading in the admin chrome
$navigationLabel static property No Text in the sidebar nav
$navigationIcon static property No Icon in the sidebar nav
$navigationGroup static property No Group heading in the sidebar nav
$navigationSort static property No Sort order within group
$slug static property No URL segment (defaults to kebab class name)
$shouldRegisterNavigation static property No Set false to hide from nav

See Also