usamamuneerchaudhary / filament-model-states
State-machine-aware timeline and Kanban board components for Filament, built on top of spatie/laravel-model-states.
Package info
github.com/usamamuneerchaudhary/filament-model-states
pkg:composer/usamamuneerchaudhary/filament-model-states
Requires
- php: ^8.3
- filament/filament: ^5.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- spatie/laravel-model-status: ^1.18
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- spatie/laravel-model-states: ^2.10
- usamamuneerchaudhary/laravel-slipway: ^1.0
Suggests
- spatie/laravel-model-states: Required if you want Kanban drag-and-drop with server-side transition validation (canTransitionTo). Without it, the Kanban board still works but allows any column move.
Provides
None
Conflicts
None
Replaces
None
README
## In Action State-aware Kanban board and status timeline components for Filament v5.
Built to sit on top of:
spatie/laravel-model-status> powers the timeline (status history log)spatie/laravel-model-states> powers transition validation and column discovery on the Kanban board (optional but recommended)
Filament Model States provides a plug and play auto-detection for enums, Spatie Model States, or plain database values.
Requirements
- PHP 8.3+
- Laravel 11–13
- Filament v5
spatie/laravel-model-status(required for timeline + optional status logging on Kanban moves)spatie/laravel-model-states(optional for server-side transition validation and config-driven column order)
Installation
composer require usamamuneerchaudhary/filament-model-states
php artisan vendor:publish --tag="filament-model-states-config"
php artisan filament:assets
After upgrading the package, re-run php artisan filament:assets and hard-refresh your browser if Kanban styles or drag-and-drop stop working.
Quick start
1. Prepare your model
Option A: backed enum (simplest)
use Spatie\ModelStatus\HasStatuses; class Order extends Model { use HasStatuses; protected function casts(): array { return [ 'workflow_state' => WorkflowState::class, ]; } }
enum WorkflowState: string { case Draft = 'draft'; case Pending = 'pending'; case Completed = 'completed'; public function label(): string { return match ($this) { self::Draft => 'Draft', self::Pending => 'Pending', self::Completed => 'Completed', }; } /** @return array<string, list<string>> */ public static function transitions(): array { return [ 'draft' => ['pending'], 'pending' => ['completed'], 'completed' => [], ]; } }
Option B: Spatie Model States
use Spatie\ModelStates\HasStates; use Spatie\ModelStatus\HasStatuses; class Booking extends Model { use HasStates; use HasStatuses; protected function casts(): array { return [ 'status' => BookingState::class, ]; } }
2. Add the Kanban to a resource list page
use Usamamuneerchaudhary\FilamentModelStates\Concerns\HasStateKanbanBoard; class BookingResource extends Resource { use HasStateKanbanBoard; // ... }
// Pages/ListBookings.php protected function getHeaderWidgets(): array { return [ BookingResource::getStateKanbanBoard(), ]; }
That's it. Columns, labels, transitions, and column order are auto-detected.
3. Verify detection (optional)
php artisan filament-model-states:make-kanban Booking --detect --state-field=status
Kanban board
Plug-and-play widget
Point the board at any model, no subclass required:
use Usamamuneerchaudhary\FilamentModelStates\Widgets\ModelStateKanbanBoard; // List page header widgets ModelStateKanbanBoard::for(Booking::class) // Non-default state field ModelStateKanbanBoard::for(Booking::class, stateField: 'status')
Resource trait (recommended)
Add HasStateKanbanBoard to your Filament resource, then register on the list page:
use Usamamuneerchaudhary\FilamentModelStates\Concerns\HasStateKanbanBoard; class OrderResource extends Resource { use HasStateKanbanBoard; }
protected function getHeaderWidgets(): array { return [ OrderResource::getStateKanbanBoard(), ]; }
Generated widget (optional)
If you prefer a dedicated widget class:
php artisan filament-model-states:make-kanban Booking --resource=BookingResource
This creates app/Filament/Widgets/BookingKanbanBoard.php and prints wiring instructions. Use --force to overwrite, --detect to inspect without generating.
Custom widget (full control)
Extend StateKanbanBoard when you need complete control over columns, queries, card content, or authorization:
use Usamamuneerchaudhary\FilamentModelStates\Widgets\StateKanbanBoard; class OrderStatusBoard extends StateKanbanBoard { protected function getModel(): string { return Order::class; } protected function getStateField(): string { return 'workflow_state'; } protected function getColumns(): array { return ['draft', 'pending', 'processing', 'completed', 'cancelled']; } protected function getAllowedTransitions(): ?array { return WorkflowState::transitions(); } protected function getColumnLabel(string $key): string { return WorkflowState::from($key)->label(); } protected function getCardTitle(Model $record): string { return "Order #{$record->reference}"; } protected function getCardSubtitle(Model $record): ?string { return $record->customer_name; } protected function getQuery(): Builder { return Order::query()->where('team_id', filament()->getTenant()->id); } }
Auto-detection
ModelStateKanbanBoard resolves everything through StateConfigurationResolver:
| Priority | Source | Columns | Transitions | Labels |
|---|---|---|---|---|
| 1 | Backed enum cast | Enum::cases() |
Enum::transitions() or Model::getStateTransitions() |
Enum::label() |
| 2 | Spatie Model States cast | BookingState::getStateMapping() |
BookingState::config() allowances |
State::label() or headline of $name |
| 3 | Database fallback | Distinct values in the state column | Model::getStateTransitions() if defined |
Headline of stored value |
State field detection
The resolver looks for, in order:
workflow_state,state, orstatusattributes in$casts- Any enum cast whose field name contains
stateorstatus - Any Spatie state cast on a field containing
stateorstatus - Any enum or Spatie state cast
Pass an explicit field when ambiguous:
ModelStateKanbanBoard::for(Booking::class, stateField: 'status')
Spatie Model States
Transitions
When your model uses spatie/laravel-model-states, the Kanban reads allowances directly from your abstract state's config():
abstract class BookingState extends State { public static function config(): StateConfig { return parent::config() ->default(Initiated::class) ->allowTransition(Initiated::class, AwaitingPayment::class, ToAwaitingPayment::class) ->allowTransition(Initiated::class, Expired::class, ToExpired::class); } }
On drag-and-drop:
- UI: only allowed target columns are highlighted; invalid targets are blocked client-side.
- Server:
$currentState->canTransitionTo($targetStateClass)is called before saving. - Persist:
$currentState->transitionTo($targetStateClass)runs your transition classes (ToAwaitingPayment, etc.). Extra constructor arguments are collected in a modal first. - History: if the model uses
HasStatuses,setStatus()is called automatically.
Parameterized transitions
If a Spatie transition constructor needs more than the model, dropping a card opens a Filament action modal instead of saving immediately.
use Filament\Forms\Components\TextInput; use Spatie\ModelStates\Transition; class ToFailed extends Transition { public function __construct( protected Transaction $transaction, public string $reason, ) {} public static function transitionForm(): array { return [ TextInput::make('reason')->label('Reason')->required(), ]; } public function handle(): Transaction { $this->transaction->failure_reason = $this->reason; $this->transaction->state = new Failed($this->transaction); $this->transaction->save(); return $this->transaction; } }
Without transitionForm(), the package maps string, int, float, bool, and backed enums to form fields. Add transitionForm() when a parameter cannot be mapped.
Turn the prompt off with filament-model-states.kanban.prompt_for_transition_arguments.
Transition abilities
Gate::authorize('update', $record) still runs. Map a specific transition to another ability so that target is hidden and rejected on the server:
protected function getTransitionAbilities(): array { return [ 'pending->approved' => 'approve-transactions', ]; }
The same ->abilities() method exists on StateSelectColumn, StateAction, and StateBulkAction.
Column keys and labels
| Property | Used for |
|---|---|
public static string $name = 'initiated' |
Column key (DB value / morph class) |
label() |
Column header label (optional) |
description() |
Not used for Kanban headers, keep this for tooltips or detail views |
Without label(), headers are generated from $name (e.g. initiated → "Initiated").
class Initiated extends BookingState { public static string $name = 'initiated'; public static function label(): string { return 'Initiated'; } public static function description(): ?string { return 'Booking has just been created'; } }
Column ordering
Columns are ordered automatically. Priority:
columnOrder()on the abstract state class (explicit override)$sortOrderorsortOrder()on each concrete state class- Config registration order: default state first, then the order
allowTransition()calls appear inconfig() - Filesystem discovery: remaining states in directory order
Explicit order example:
abstract class BookingState extends State { public static function columnOrder(): array { return [ 'initiated', 'awaiting_payment', 'pending', 'confirmed', 'completed', 'cancelled', ]; } }
Per-state sort order:
class Initiated extends BookingState { public static int $sortOrder = 1; public static string $name = 'initiated'; }
Enum-based workflows
Define transitions on the enum:
public static function transitions(): array { return [ 'draft' => ['pending', 'cancelled'], 'pending' => ['processing', 'cancelled'], 'processing' => ['completed', 'failed'], 'completed' => [], 'cancelled' => ['draft'], 'failed' => ['pending'], ]; }
Or on the model:
public static function getStateTransitions(): array { return WorkflowState::transitions(); }
Enum case order in the PHP file is used as column order.
Optional model hooks
Search
Add a query scope to enable the board's search box:
public function scopeFilamentKanbanSearch(Builder $query, string $search): Builder { return $query->where(function (Builder $query) use ($search): void { $query->where('reference', 'like', "%{$search}%") ->orWhere('customer_name', 'like', "%{$search}%"); }); }
Status history on Kanban moves
Use HasStatuses on the model. Each successful drag calls:
$record->setStatus($targetColumn, 'Moved via Kanban board');
Customize the reason by extending StateKanbanBoard::persistStateChange(). The optional fourth argument is the positional list passed to transitionTo().
Table components
use Usamamuneerchaudhary\FilamentModelStates\Actions\StateAction; use Usamamuneerchaudhary\FilamentModelStates\Actions\StateBulkAction; use Usamamuneerchaudhary\FilamentModelStates\Tables\Columns\StateSelectColumn; use Usamamuneerchaudhary\FilamentModelStates\Tables\Filters\StateSelectFilter; StateSelectColumn::make('workflow_state') ->abilities([ 'draft->pending' => 'submit-orders', ]); StateAction::make('approve') ->target('approved') ->stateField('workflow_state'); StateBulkAction::make('approveSelected') ->target('approved'); StateSelectFilter::make('workflow_state');
StateSelectColumn only lists the current state and transitions the record is allowed to make. A rejected change restores the previous value and sends a notification. Transitions that need extra input stay on StateAction, which opens the same modal as the Kanban board. StateBulkAction updates each selected record that can make the transition and reports how many were skipped.
Spatie state class resolution
If column keys don't map cleanly to state classes, add to your model:
public static function getStateClassForColumn(string $column): string { return match ($column) { 'awaiting_payment' => AwaitingPayment::class, default => throw new InvalidArgumentException("Unknown column [{$column}]"), }; }
Status timeline (Infolist)
Shows a vertical history of status changes from spatie/laravel-model-status.
Basic usage
use Usamamuneerchaudhary\FilamentModelStates\Infolists\Components\StatusTimelineEntry; public static function infolist(Schema $schema): Schema { return $schema->components([ StatusTimelineEntry::make('status_history') ->label('Status history') ->limit(20), ]); }
Resource trait
use Usamamuneerchaudhary\FilamentModelStates\Concerns\HasStatusTimelineInfolist; class OrderInfolist { use HasStatusTimelineInfolist; public static function configure(Schema $schema): Schema { return $schema->components([ Section::make('Status history') ->schema([ self::statusTimelineEntry(), ]), ]); } }
The timeline reads $record->statuses(), shows each state with reason and duration, and highlights the current one. Status labels match the Kanban board (enum label(), Spatie state label(), or a headline fallback).
Options
StatusTimelineEntry::make('status_history') ->limit(20) // max entries shown (no pagination UI) ->hideReason() // hide the reason text ->emptyStateLabel('No changes yet.');
Kanban features
- Drag-and-drop between columns with SortableJS (bundled, no CDN)
- Server-side validation: invalid moves are rejected with a notification; cards snap back
- Confirmation modal before saving a move (configurable)
- Search: filters cards when
scopeFilamentKanbanSearchis defined - Column filter: dropdown to focus on a single column
- Totals: SQL counts per column; cards are paginated per column
- Transition hints: "Can move to" chips under each column header
- Authorization:
Gate::authorize('update', $record)on every move
Artisan command
# Inspect auto-detected configuration php artisan filament-model-states:make-kanban Booking --detect # Specify a non-default state field php artisan filament-model-states:make-kanban Booking --detect --state-field=status # Generate a widget class php artisan filament-model-states:make-kanban Booking --resource=BookingResource # Overwrite an existing generated widget php artisan filament-model-states:make-kanban Booking --force
Example --detect output:
Model App\Models\Booking
State field status
Detection source spatie-model-states
Columns initiated, awaiting_payment, pending, ...
Transitions {"initiated":["awaiting_payment","expired"], ...}
Configuration
Published to config/filament-model-states.php:
return [ 'timeline' => [ 'per_page' => 25, 'show_reason' => true, 'date_format' => 'M j, Y \a\t g:i A', ], 'kanban' => [ // Filament color tokens per column key 'column_colors' => [ 'draft' => 'gray', 'pending' => 'warning', 'processing' => 'info', 'completed' => 'success', 'cancelled' => 'danger', 'failed' => 'danger', ], // Show a danger notification when a drag is rejected 'notify_on_invalid_transition' => true, // Max cards rendered per column (totals always use SQL counts) 'records_per_column' => 25, // Confirm in a modal before persisting a move 'confirm_before_move' => true, ], ];
Unlisted column keys fall back to gray. Override per-widget via getColumnColor() on a custom StateKanbanBoard subclass.
How transition validation works
When a card is dropped on a new column, moveRecord() runs server-side:
- Loads the record with a row lock.
- Authorizes
updateon the record. - Checks the transition:
- Spatie Model States:
$state->canTransitionTo($targetStateClass) - Enum / manual map:
getAllowedTransitions()array - No rules: all moves allowed
- Spatie Model States:
- Persists via
transitionTo()(Spatie) or direct attribute save (enum/DB). - Optionally logs to status history via
setStatus().
The browser cannot bypass step 3, enforcement lives in your state machine, not JavaScript.
Where to register the board
| Location | How |
|---|---|
| Resource list page (recommended) | getHeaderWidgets() → YourResource::getStateKanbanBoard() |
| Custom page | ModelStateKanbanBoard::for(YourModel::class) in getHeaderWidgets() or getFooterWidgets() |
| Panel widgets | Only if you want a global board, usually prefer resource-scoped |
Compatibility
- Filament v5 (
filament/filament ^5.0) - Laravel 11–13
- PHP 8.3+
StatusTimelineEntryuses the Filament v5 schemas/infolists API- Kanban CSS and JS are bundled via
FilamentAsset, runphp artisan filament:assetsafter install