Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Spatie Laravel Model States Actions Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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 + color
  • OrderState::textEntry() — infolist TextEntry
  • $this->record->state->display() — returns a Filament\Actions\Action, usable anywhere an Action fits

All 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:

  • It pairs naturally with actionsAsGroup()/tableActions() on queue-backed operational screens (approvals, fulfilment, ticket triage), where the person clicking and the person watching are different users.
  • If your app already notifies from model observers or events, you will now have two notification paths. Pick one owner of that message — either suppress the package's or drop the observer — before it ships. See Gotchas.

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.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
teamzac/geocoder
ideatica/config-writer
keaton-php/pipe-dream
jacksonwebservices/laravel-user-suite
maksimru/conditional-laravel-debugbar
gismatul/nova-support-chat
gismatul/laravel-support-chat
networkrailbusinesssystems/oracle-api
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel