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 callingparent::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'sbroadcastNotificationto 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
beforeunloaddialogs 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.