Components

Notifications and Actions

How Tagixo builder events map to Filament notifications and header action patterns.

Notifications and Actions

BuilderPage is a Livewire component at its core. Every UI feedback mechanism — save confirmations, error alerts, header buttons, confirmation dialogs — flows through standard Filament v5 patterns. This page covers the full lifecycle: how the builder reports success and failure, how to chain downstream notifications, and how to wire up header actions with confirmation guards.

Save Notifications

BuilderPage wraps its persistence cycle in a try/catch block. Two distinct Filament notification paths exist: one for success, one for any thrown exception.

Success notification

After saveStructure() persists the builder payload, the base class dispatches a Filament success notification automatically:

use Filament\Notifications\Notification;

// Inside BuilderPage — already done for you by the base class:
Notification::make()
    ->title(__('Structure saved'))
    ->success()
    ->send();

If you override saveStructure() in your concrete page, call parent::saveStructure() before appending your own notifications, so the base success message fires first and your additions stack below it:

protected function saveStructure(): void
{
    parent::saveStructure(); // fires base success notification

    Notification::make()
        ->title(__('Cache cleared'))
        ->body(__('CDN cache purged for this page.'))
        ->success()
        ->duration(4000)
        ->send();
}

Tip: Filament stacks multiple notifications in the order send() is called. The most recently sent notification appears at the top of the toast stack. If you want your downstream notification to appear above the generic save message, dispatch it before calling parent::saveStructure().

Error notification

Any \Throwable that escapes saveStructure() is caught by BuilderPage::save() and converted into a Filament danger notification:

// Base class behaviour — already wired:
try {
    $this->saveStructure();
} catch (\Throwable $e) {
    Notification::make()
        ->title(__('Save failed'))
        ->body($e->getMessage())
        ->danger()
        ->persistent()   // stays until dismissed
        ->send();

    return;
}

The notification is persistent() — it does not auto-dismiss — so the user cannot miss it. The raw exception message is surfaced as the body; in production you may want to sanitise this:

protected function saveStructure(): void
{
    try {
        $this->persistPayload();
    } catch (\RuntimeException $e) {
        // Re-throw a sanitised version; BuilderPage catches it upstream
        throw new \RuntimeException(__('Could not write page structure. Check storage permissions.'), 0, $e);
    }
}

If you want to handle the error yourself and suppress the base notification, catch the exception inside your override and do not re-throw:

protected function saveStructure(): void
{
    try {
        $this->persistPayload();
    } catch (\Throwable $e) {
        report($e); // Laravel's exception reporter

        Notification::make()
            ->title(__('Sync failed'))
            ->body(__('Page saved locally but could not sync to CDN.'))
            ->warning()
            ->persistent()
            ->send();
        // No re-throw → base class sees no exception → no duplicate danger notification
    }
}

Chaining Downstream Notifications

When saveStructure() triggers side-effects — publishing, cache clearing, webhook dispatch, search-index sync — each step can carry its own notification. The pattern is straightforward: call Notification::make()->send() after each step succeeds, or catch per-step failures and issue targeted warnings without aborting the whole save.

protected function saveStructure(): void
{
    parent::saveStructure();

    // Step 1 — publish
    try {
        PublishService::publish($this->record);

        Notification::make()
            ->title(__('Page published'))
            ->success()
            ->send();
    } catch (\Throwable $e) {
        report($e);

        Notification::make()
            ->title(__('Publish failed'))
            ->body($e->getMessage())
            ->warning()
            ->persistent()
            ->send();
    }

    // Step 2 — purge CDN
    try {
        CdnService::purge($this->record->slug);

        Notification::make()
            ->title(__('CDN purged'))
            ->success()
            ->duration(3000)
            ->send();
    } catch (\Throwable $e) {
        report($e);

        Notification::make()
            ->title(__('CDN purge failed'))
            ->body(__('Changes are live but cached copies may be stale.'))
            ->warning()
            ->send();
    }
}

Warning: Each Notification::make()->send() call renders a separate toast. More than three simultaneous toasts becomes noise. For multi-step pipelines, consider aggregating results into a single notification body, or using Filament's broadcastNotification to send a summary after all steps complete.

Header Actions

BuilderPage exposes getHeaderActions() exactly like any Filament page. Return an array of Action instances to attach buttons to the builder page header bar.

use Filament\Actions\Action;

protected function getHeaderActions(): array
{
    return [
        Action::make('preview')
            ->label(__('Preview'))
            ->icon('heroicon-o-eye')
            ->url(fn () => route('public.page', $this->record->slug))
            ->openUrlInNewTab(),

        Action::make('publish')
            ->label(__('Publish'))
            ->icon('heroicon-o-globe-alt')
            ->color('success')
            ->action(fn () => $this->publish()),
    ];
}

Actions are rendered left-to-right in the header. The builder's own Save button is managed by the base class and always appears; your custom actions appear alongside it.

Grouping actions

If you add more than two or three actions, wrap secondary ones in an ActionGroup:

use Filament\Actions\ActionGroup;

protected function getHeaderActions(): array
{
    return [
        Action::make('preview')
            ->label(__('Preview'))
            ->icon('heroicon-o-eye')
            ->url(fn () => route('public.page', $this->record->slug))
            ->openUrlInNewTab(),

        ActionGroup::make([
            Action::make('duplicate')
                ->label(__('Duplicate page'))
                ->icon('heroicon-o-document-duplicate')
                ->action(fn () => $this->duplicate()),

            Action::make('export')
                ->label(__('Export JSON'))
                ->icon('heroicon-o-arrow-down-tray')
                ->action(fn () => $this->exportJson()),
        ])
        ->icon('heroicon-m-ellipsis-vertical')
        ->tooltip(__('More')),
    ];
}

Confirmation Modals

Destructive or irreversible actions — publishing to production, resetting the page, deleting a layout — should require confirmation. Filament's Action::requiresConfirmation() renders a modal before executing the action callback.

Action::make('reset')
    ->label(__('Reset to default'))
    ->icon('heroicon-o-arrow-path')
    ->color('danger')
    ->requiresConfirmation()
    ->modalHeading(__('Reset page content?'))
    ->modalDescription(__('This will discard all builder changes and restore the default layout. This cannot be undone.'))
    ->modalSubmitActionLabel(__('Yes, reset'))
    ->action(fn () => $this->resetToDefault()),

For more control — custom form fields inside the modal, conditional labels — use ->form() inside the action:

Action::make('publish')
    ->label(__('Publish'))
    ->icon('heroicon-o-globe-alt')
    ->color('success')
    ->form([
        \Filament\Forms\Components\Checkbox::make('notify_subscribers')
            ->label(__('Notify newsletter subscribers'))
            ->default(false),
    ])
    ->action(function (array $data): void {
        $this->publishPage(notify: $data['notify_subscribers']);
    }),

Tip: requiresConfirmation() and ->form() are mutually exclusive in Filament v5. If you need a confirmation plus form fields, use ->form() — the submit button acts as the confirmation trigger.

Unsaved Changes Guard

The builder tracks in-flight edits via a reactive property. To warn users who navigate away with unsaved changes, set $unsavedChangesConfirmation on your page class:

use Ccast\TagixoFilament\Pages\BuilderPage;

class HomeBuilderPage extends BuilderPage
{
    protected bool $unsavedChangesConfirmation = true;

    // ...
}

When true, Filament injects a browser beforeunload listener. If the user clicks a sidebar link or closes the tab while the builder has uncommitted changes, the browser's native "Leave site?" dialog appears.

Gotcha: Browser-native beforeunload dialogs cannot be styled. They are controlled by the browser, not Filament. The message text is also browser-controlled on most modern browsers, regardless of any custom string you set.

The guard activates only after the user makes at least one change. A freshly loaded page with no edits does not trigger it.

If you want finer control — suppressing the guard for certain navigation paths, or triggering it programmatically — dispatch Livewire events directly:

// Arm the guard from PHP
$this->dispatch('builder-page:unsaved-changes');

// Disarm after a successful save
$this->dispatch('builder-page:changes-saved');

These event names are internal to the base class; consult the source if you need to hook into them.

Complete Example: Preview and Publish Actions

The following is a self-contained builder page with:

  • A Preview button that opens the public page in a new tab
  • A Publish button with a confirmation modal and downstream notifications
  • An unsaved-changes guard
<?php

namespace App\Filament\Pages;

use Ccast\TagixoFilament\Pages\BuilderPage;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use App\Services\PublishService;
use App\Services\CdnService;

class HomeBuilderPage extends BuilderPage
{
    protected static string $slug = 'builder/home';

    protected static ?string $navigationLabel = 'Home Page Builder';

    protected bool $unsavedChangesConfirmation = true;

    protected function getHeaderActions(): array
    {
        return [
            Action::make('preview')
                ->label(__('Preview'))
                ->icon('heroicon-o-eye')
                ->color('gray')
                ->url(fn () => route('public.page', $this->record->slug))
                ->openUrlInNewTab(),

            Action::make('publish')
                ->label(__('Publish'))
                ->icon('heroicon-o-globe-alt')
                ->color('success')
                ->modalHeading(__('Publish to production?'))
                ->modalDescription(
                    __('This will make the current saved version of the page live. Unsaved builder changes will not be included — save first.')
                )
                ->modalSubmitActionLabel(__('Publish'))
                ->requiresConfirmation()
                ->action(fn () => $this->publishPage()),
        ];
    }

    protected function saveStructure(): void
    {
        parent::saveStructure();

        // Disarm the unsaved-changes guard after a successful save
        $this->dispatch('builder-page:changes-saved');
    }

    protected function publishPage(): void
    {
        // Guard: the record must have been saved at least once
        if (! $this->record->exists) {
            Notification::make()
                ->title(__('Nothing to publish'))
                ->body(__('Save the page before publishing.'))
                ->warning()
                ->send();

            return;
        }

        try {
            PublishService::publish($this->record);
        } catch (\Throwable $e) {
            report($e);

            Notification::make()
                ->title(__('Publish failed'))
                ->body($e->getMessage())
                ->danger()
                ->persistent()
                ->send();

            return;
        }

        try {
            CdnService::purge($this->record->slug);
        } catch (\Throwable $e) {
            report($e);

            Notification::make()
                ->title(__('Page published — CDN purge failed'))
                ->body(__('Changes are live but cached copies may take time to expire.'))
                ->warning()
                ->persistent()
                ->send();

            return;
        }

        Notification::make()
            ->title(__('Page published'))
            ->body(__('The page is now live and CDN cache has been purged.'))
            ->success()
            ->send();
    }
}

Action visibility and authorization

If publish should only be available to certain roles, gate the action with ->visible() or ->authorize():

Action::make('publish')
    ->label(__('Publish'))
    ->visible(fn () => auth()->user()->can('publish', $this->record))
    // ...

Filament evaluates ->visible() on every render cycle. Keep the closure lightweight — avoid database queries inside it. Cache the permission result on the component if needed:

public bool $canPublish = false;

public function mount(): void
{
    parent::mount();
    $this->canPublish = auth()->user()->can('publish', $this->record);
}

protected function getHeaderActions(): array
{
    return [
        Action::make('publish')
            ->visible(fn () => $this->canPublish)
            // ...
    ];
}

Disabling actions during save

To prevent double-clicks on the Publish button while a save is in progress, disable the action while Livewire is processing:

Action::make('publish')
    ->label(__('Publish'))
    ->disabled(fn () => $this->isSaving ?? false)
    // ...

The $isSaving property is managed by the base class and toggled around the saveStructure() call.

See also