Builder Pages

Standalone Builder Page

Use BuilderPage for standalone editing — implement loadStructure, saveStructure, and getContext.

BuilderPage

BuilderPage is the Primix base class for admin pages that expose a structured configuration UI — a form, a table, or any static layout — backed by a single Eloquent model or a raw configuration store. It composes the Primix HasForm, HasNavigation, and InteractsWithRecord traits so you can focus on declaring the shape of the page rather than wiring lifecycle hooks.

Heads-up: If you need a full drag-and-drop visual builder canvas (the Tagixo page editor), extend PrimixVisualBuilderPage instead. The signal is simple: if the page needs a canvas with module slots, use the visual builder; if it needs a form or a table, use BuilderPage.


When to use BuilderPage

Scenario Use
Editing a single record that always exists (site settings, homepage meta) BuilderPage
Listing and editing multiple records ListPage + EditPage (or a custom BuilderPage with a table component)
A page that lets the user arrange modules visually PrimixVisualBuilderPage
A dashboard or read-only summary with no form BuilderPage (implement form() to return an empty schema)

Extending BuilderPage

Place the class anywhere under app/Primix/ — Primix auto-discovers pages in directories registered with AdminPanelProvider::discoverPages(). The conventional location is:

app/Primix/Pages/MyPage.php
namespace App\Primix\Pages;

use Primix\Pages\BuilderPage;

class MyPage extends BuilderPage
{
    // implement required methods
}

You do not need to use any traits manually; BuilderPage already composes them.


Required methods

form(Schema $schema): Schema

Declares the form schema rendered on the page. Return the $schema object with your fields attached.

Parameter Type Description
$schema Primix\Forms\Schema Empty schema instance injected by the framework
use Primix\Forms\Schema;
use Primix\Forms\Fields\TextInput;
use Primix\Forms\Fields\Toggle;

public function form(Schema $schema): Schema
{
    return $schema->fields([
        TextInput::make('site_name')->required(),
        Toggle::make('maintenance_mode'),
    ]);
}

loadData(): array

Called once when the page mounts. Return an associative array whose keys match the field names declared in form().

Return key Expected value
Any field name The current persisted value for that field
public function loadData(): array
{
    $settings = \App\Models\SiteSettings::first();

    return [
        'site_name'        => $settings?->site_name,
        'maintenance_mode' => $settings?->maintenance_mode ?? false,
    ];
}

saveStructure(array $data): void

Called when the user submits the form. Receives the validated form data. Persist it however your domain requires.

Parameter Type Description
$data array Validated field values, keyed by field name

Warning: Do not swallow exceptions silently inside saveStructure. If you catch \Exception for logging, always re-throw (or throw a Primix\Exceptions\SaveException) so the framework can display the error banner to the user. Silent failures are a common footgun — the user sees the success toast while the data was never written.

public function saveStructure(array $data): void
{
    \App\Models\SiteSettings::updateOrCreate(
        [],
        [
            'site_name'        => $data['site_name'],
            'maintenance_mode' => $data['maintenance_mode'],
        ]
    );
}

Artisan scaffold

Generate a skeleton with the correct namespace and method stubs:

php artisan primix:make-page MyPage --context=page

Generated output (abbreviated):

namespace App\Primix\Pages;

use Primix\Forms\Schema;
use Primix\Pages\BuilderPage;

class MyPage extends BuilderPage
{
    protected static ?string $navigationLabel = 'My Page';
    protected static ?string $navigationIcon  = 'heroicon-o-document';
    protected static ?int    $navigationSort  = 10;

    public function form(Schema $schema): Schema
    {
        return $schema->fields([
            //
        ]);
    }

    public function loadData(): array
    {
        return [];
    }

    public function saveStructure(array $data): void
    {
        //
    }
}

AdminPanelProvider registration

Auto-discovery (recommended)

// app/Providers/AdminPanelProvider.php
$panel->discoverPages(
    in: app_path('Primix/Pages'),
    for: 'App\\Primix\\Pages',
);

All classes in the directory that extend a Primix page base are registered automatically. No further action needed when you add a new page.

Manual registration

$panel->pages([
    \App\Primix\Pages\MyPage::class,
]);

Use manual registration when you need fine-grained control over which pages appear in a given panel.


Navigation configuration

Static properties set the default navigation metadata:

Property Type Default Description
$navigationLabel ?string Class name (humanised) Sidebar label
$navigationIcon ?string null Heroicon identifier
$navigationGroup ?string null Group heading in the sidebar
$navigationSort ?int null Sort order within the group

Override with methods for i18n or dynamic values:

public static function getNavigationLabel(): string
{
    return __('admin.pages.my_page');
}

public static function getNavigationGroup(): ?string
{
    return __('admin.groups.settings');
}

Authorization

Override canAccess() to restrict who can open the page. The method is called before the page renders.

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

Three common patterns:

Role check

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

Gate

public static function canAccess(): bool
{
    return \Illuminate\Support\Facades\Gate::allows('view-settings-page');
}

Super-admin flag

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

Performance note: canAccess() is evaluated on every request that touches the sidebar (to determine whether to render the nav item). Keep it cheap — avoid N+1 queries or external HTTP calls. Cache aggressively if the check is expensive.


Preview URL

By default, BuilderPage hides the preview button. To enable it, override getPreviewUrl():

public function getPreviewUrl(): ?string
{
    return route('home');
}

For unpublished pages where the public route returns 404, generate a signed URL:

public function getPreviewUrl(): ?string
{
    $record = \App\Models\Page::find($this->record?->id);

    if (! $record) {
        return null;
    }

    return \Illuminate\Support\Facades\URL::signedRoute(
        'preview.page',
        ['page' => $record->slug],
        now()->addMinutes(30),
    );
}

Guard note: Ensure your preview route verifies the signature with the signed middleware and checks that the authenticated user is an admin, otherwise the signed URL is a public bypass for unpublished content.


Complete example

Model

// app/Models/MarketingPage.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class MarketingPage extends Model
{
    protected $table = 'marketing_page';
    public $timestamps = true;

    protected $casts = [
        'hero_visible' => 'boolean',
    ];
}

BuilderPage

// app/Primix/Pages/HomepageBuilder.php
namespace App\Primix\Pages;

use App\Models\MarketingPage;
use Primix\Forms\Fields\TextInput;
use Primix\Forms\Fields\Textarea;
use Primix\Forms\Fields\Toggle;
use Primix\Forms\Schema;
use Primix\Pages\BuilderPage;

class HomepageBuilder extends BuilderPage
{
    protected static ?string $navigationLabel = 'Homepage';
    protected static ?string $navigationIcon  = 'heroicon-o-home';
    protected static ?string $navigationGroup = 'Content';
    protected static ?int    $navigationSort  = 1;

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

    public function form(Schema $schema): Schema
    {
        return $schema->fields([
            TextInput::make('hero_title')
                ->label('Hero title')
                ->required()
                ->maxLength(120),

            Textarea::make('hero_subtitle')
                ->label('Hero subtitle')
                ->rows(3),

            Toggle::make('hero_visible')
                ->label('Show hero section')
                ->default(true),
        ]);
    }

    public function loadData(): array
    {
        $page = MarketingPage::first();

        return [
            'hero_title'    => $page?->hero_title ?? '',
            'hero_subtitle' => $page?->hero_subtitle ?? '',
            'hero_visible'  => $page?->hero_visible ?? true,
        ];
    }

    public function saveStructure(array $data): void
    {
        MarketingPage::updateOrCreate([], [
            'hero_title'    => $data['hero_title'],
            'hero_subtitle' => $data['hero_subtitle'],
            'hero_visible'  => $data['hero_visible'],
        ]);

        // Bust the public page cache so visitors see the change immediately.
        \Illuminate\Support\Facades\Cache::forget('public.homepage');
    }

    public function getPreviewUrl(): ?string
    {
        return route('home');
    }
}

After creating the page, clear Octane's opcode cache:

php artisan optimize:clear
php artisan octane:reload

First load

  1. Primix calls HomepageBuilder::canAccess() — renders the nav item only for editors.
  2. On navigation, loadData() fetches the single MarketingPage row and populates the form.
  3. The user edits fields and clicks Save.
  4. saveStructure() upserts the row, then clears the homepage cache key.

Subsequent reloads

The form mounts fresh on each page load (Livewire full-page component). loadData() is called again, so the form always reflects the database state — there is no client-side stale state to worry about.


See also