abather/spatie-laravel-model-states-actions
Add state-based actions to Laravel views with this extension for spatie/laravel-model-states. Define per-state authorization abilities, skip checks when needed, and customize action icons and colors. Includes transition checks via canTransitionTo.
Install the package alongside its two hard dependencies — Spatie's state machine and Filament:
composer require abather/spatie-laravel-model-states-actions
Before writing any state code, confirm your dependency matrix. v2.x targets Filament v4 and a higher PHP floor; v1.x is the Filament v3 line. v2.1.1 is the current patch on that line — it adds notification support and live refresh for transitions, but changes no namespaces. Pin explicitly in composer.json if you are not yet on Filament 4, otherwise Composer will happily resolve you into a version whose actions/entry APIs no longer match your codebase.
The one mandatory migration step: your abstract base state must extend the package's State, not Spatie\ModelStates\State. If you already have states in production, this is a use statement swap in every concrete state file plus the abstract parent.
use Abather\SpatieLaravelModelStatesActions\State;
use Spatie\ModelStates\StateConfig;
abstract class OrderState extends State
{
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Paid::class)
->allowTransition(Pending::class, Canceled::class);
}
}
First use case to validate the install end-to-end: an order table showing the current state as a column and the legal transitions as row actions.
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('reference'),
OrderState::textColumn(),
])
->actions([
...StateActionsService::make(static::getModel())->tableActions(),
]);
}
If buttons appear and clicking one flips the badge, the package is wired correctly. If the column renders but no action buttons show, jump straight to Gotchas — it is almost always authorization or the transition graph, not the install. Once the bare flow works, re-run one transition and watch for the v2.1.1 additions: a success notification should surface, and a second browser tab on the same table should reflect the new state without a manual reload.
One abstract base state per domain, thin concrete states. Put config(), $requires_confirmation, and any shared defaults on the base; keep concrete states to the four knobs the package reads: $ability, $color, $icon, $order.
Authorization is per-state, keyed to a policy method. This is the package's central design decision and the pattern you will live in:
class Canceled extends OrderState
{
protected static ?string $ability = 'cancel';
protected static ?string $color = 'danger';
protected static ?string $icon = 'heroicon-o-x-circle';
public static int $order = 20;
}
StateActionsService resolves that ability against your model policy and calls Spatie's canTransitionTo() before surfacing the action. Treat the two as a pair: policy says may this user ever do this, transition graph says is it legal right now. You rarely need to write extra guards.
Table actions vs. page actions vs. grouped actions — same service, three shapes:
// Table row actions
...StateActionsService::make(static::getModel())->tableActions(),
// Any Filament page header
...StateActionsService::make(Contract::class)->actions(),
// Grouped dropdown, excluding the current state, label = current state
StateActionsService::make(Contract::class, 'importance')
->excludeStates($this->record->importance)
->actionsAsGroup($this->record),
excludeStates() is the idiomatic way to build "what can I do from here" menus. Passing the record is what makes the group label show the current state — omit it and you get an unlabeled button group.
Ordering is a convention, not a guarantee. $order sorts actions numerically; unset states fall wherever they fall. Assign every state in a family an explicit order or accept unpredictable grouping.
Display surfaces are three, and they are not interchangeable:
OrderState::textColumn() — table column with icon + colorOrderState::textEntry() — infolist TextEntry$this->record->state->display() — returns a Filament\Actions\Action, usable anywhere an Action fitsAll three accept without_icon: true.
Form select has an auth-aware variant. formSelect(static::getModel()) builds a state dropdown with no authorization check; formSelectWithAuth(...) (added 1.0.8) filters options by policy and transition legality. Default to the auth variant on any form a non-admin can reach, or you have effectively created a privilege-escalation field.
Filters slot into the standard ->filters() array:
->filters([
StateFilterService::make(static::getModel())->tableFilter(),
])
Notifications and live refresh (new in 2.1.1). Transitions executed through the package's actions can now raise a Filament notification and refresh the surrounding UI, so a reviewer does not have to reload to see that an order moved. This is a behaviour of the action pipeline, not of Spatie's state machine — it lives in the same StateActionsService call you already make, so existing resources inherit it without code changes. Two practical consequences:
actionsAsGroup()/tableActions() on queue-backed operational screens (approvals, fulfilment, ticket triage), where the person clicking and the person watching are different users.Because 2.1.1 is a patch with a single-line changelog, confirm the exact opt-out/opt-in surface against StateActionsService in vendor/ rather than assuming property names; the underlying PR is the only documentation.
Localization lives in lang/{locale}/states.php, keyed by the class basename:
'Canceled' => [
'title' => 'Canceled',
'label' => 'Cancel order',
],
title is the display text in columns/entries; label is the action button text. Both keys are read, so provide both even if they are identical — a missing label typically surfaces as a raw class name on the button.
Extension points you can rely on: override the base class to bulk-set $requires_confirmation, $color, $icon, and $skip_authorization; wrap StateActionsService in your own helper if you need cross-cutting concerns (telemetry, permission pre-checks, tenant scoping, notification copy) without touching every resource.
No buttons ≠ broken package. The overwhelmingly common cause is a silent authorization or transition failure. Debug in this order: (1) Gate::forUser($user)->allows('cancel', $order) in tinker; (2) whether config() actually declares the transition; (3) whether the state class is registered/discoverable by Spatie at all. A state class not returned by the model's state config will never render an action, no matter what the policy says.
A missing policy method is a trap. If $ability = 'cancel' but OrderPolicy has no cancel() method, most Laravel Gate setups deny by default — you get a silently missing button with no error. Either define every ability or set protected static ?bool $skip_authorization = true deliberately and narrowly. Note that since 1.0.6 canTransitionTo() still gates actions even when authorization is skipped, so $skip_authorization is not a full bypass — it only removes the policy check.
Localization keys are class basenames, not FQCNs. Two states named Approved in different namespaces (e.g. Order\Approved and Invoice\Approved) collide in states.php. If you have parallel state machines, prefix the keys or accept that they share copy.
$requires_confirmation inherits down the hierarchy. Setting it to false on the abstract base disables confirmation modals for every state beneath it, including destructive ones like Canceled. Set it per-state unless you genuinely want a blanket policy.
Icon and color values are Filament's, not yours. $color must be a Filament color name (danger, warning, success, primary, gray, …); $icon must be a valid Heroicon identifier (heroicon-o-… for outline). Invalid values fail at render time, often as a blank badge rather than a clear exception.
Notifications from 2.1.1 fire on the action path only. Any transition your app performs directly ($order->state->transitionTo(Paid::class) in a job, webhook, import, or console command) will not produce the package notification and will not trigger the live refresh — that is the correct behaviour, but it means "some transitions are silent" is expected, not a bug. If you want uniform messaging, emit it from a model event listener instead of relying on the action wrapper. Also watch for duplicate notifications in resources that already call Notification::make()->send() inside a ->after()/->success() hook on the same action.
Live refresh needs a delivery mechanism. Refreshing another user's screen depends on Filament's polling or broadcasting being enabled and, for broadcast, a working queue connection, Echo setup, and BROADCAST_CONNECTION that is not log. If only the actor's own table updates, the feature is working and the transport is not. Test it with two real browser sessions, not a single session and a tinker call.
Version skew is the biggest operational risk here. The package has 4 stars, 0 dependents, and a Filament-major-version-breaking release line, now on its third patch of v2 within a month of the major. Vendor risk is real: pin a minor version (^2.1.1) in composer.json, and keep all package calls localized to your Filament resources and one service wrapper so a future fork or replacement is a small diff rather than a codebase-wide rewrite. Do not spread StateActionsService::make() into domain/business code.
actionsAsGroup() without a record silently loses the current-state label. The signature accepts $record optionally, and the omission is not an error — you just get a less informative dropdown. Always pass the record when you have one in scope.
formSelect bypasses authorization by default. This is the one place where the ergonomic API and the safe API differ. Use formSelectWithAuth on anything user-facing, and audit existing forms after upgrading from a pre-1.0.8 release.
Ordering and exclusion interact. excludeStates($this->record->importance) removes the current state from the group; if you also rely on $order for stable positioning, verify the remaining order after exclusion rather than assuming indices hold.
Testing. The package ships composer test, but your app-level coverage matters more: assert the action array length for a given user/state combination (StateActionsService::make(Order::class)->actions() returns plain Filament Action objects — countable and inspectable), and assert that transitions actually persist. A regression in your config() transition graph will present as a UI bug, so test the graph directly rather than through Filament's test helpers where you can avoid it. After 2.1.1, add one notification assertion (Notification::assertNotified() against a transition action) so a future patch cannot silently stop notifying — and one test proving your observer-based notifications are not doubling up.
How can I help you explore Laravel packages today?