Laravel & PHP ← Back to blog

Building an Admin Panel with Filament 4

30 April 2026 · Matt

Key patterns from the Filament 4 PostResource — custom publish actions, RichEditor, and dehydration control.

The Publish UX Problem

Blog posts need a clear Published/Draft state. A simple boolean toggle is confusing — users don't know if "on" means published or live. The solution: a status Select that maps to the published_at timestamp.

Select::make('status')
    ->options(['published' => 'Published', 'draft' => 'Draft'])
    ->dehydrated(false)   // don't write 'status' to DB
    ->live()
    ->afterStateHydrated(function (Select $component, $record) {
        $component->state(
            $record && $record->published_at !== null ? 'published' : 'draft'
        );
    })
    ->afterStateUpdated(function (Set $set, ?string $state) {
        $set('published_at', $state === 'published' ? now() : null);
    }),

The key is ->dehydrated(false) — Filament won't include the status field in the data it saves to the model. Instead, afterStateUpdated sets the real published_at field.

One-Click Publish in the Table

Action::make('togglePublish')
    ->label(fn ($record) => $record->published_at ? 'Unpublish' : 'Publish')
    ->icon(fn ($record) => $record->published_at ? 'heroicon-o-eye-slash' : 'heroicon-o-eye')
    ->color(fn ($record) => $record->published_at ? 'warning' : 'success')
    ->action(fn ($record) => $record->update([
        'published_at' => $record->published_at ? null : now(),
    ]))
    ->requiresConfirmation(fn ($record) => (bool) $record->published_at),

Filament 4 Import Gotcha

In Filament 4 the namespaces changed. Custom table row actions live in Filament\Actions\Action, not Filament\Tables\Actions\Action. Using the wrong import causes a silent failure where the action appears but does nothing.

// Correct in Filament 4:
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
Watch out: Filament 4 also removed BadgeColumn. Replace it with TextColumn::make()->badge()->color(fn (string $state) => match($state) { ... }).