abarbod/laravel-state-machine

A production-ready state machine package for Laravel Eloquent models

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/abarbod/laravel-state-machine

v0.1.2 2026-01-02 19:42 UTC

This package is auto-updated.

Last update: 2026-03-02 20:01:01 UTC


README

A production-ready state machine package for Laravel Eloquent models. Enforce state transitions, prevent invalid state changes, and maintain a complete audit trail.

Features

  • Formal State Machine: Define states and transitions with a fluent API
  • Guard Conditions: Prevent invalid transitions with guard callbacks
  • Hooks: Execute code before, after, or on failure of transitions
  • History Tracking: Complete audit trail of all state changes
  • Concurrency Safety: Optimistic locking prevents race conditions
  • Strict Mode: Prevent direct state assignment (must use transitions)
  • Multi-State Support: Multiple independent state machines per model
  • Diagram Generation: Visualize state machines as Mermaid or DOT diagrams
  • Testing Helpers: Assertions for testing state transitions

Installation

composer require abarbod/laravel-state-machine

Publish the configuration file:

php artisan vendor:publish --tag=state-machine-config

Run migrations:

php artisan migrate

Quick Start

1. Define a State Machine

In your AppServiceProvider or a dedicated service provider:

use Laravel\StateMachine\Facades\StateMachine;
use App\Models\Order;

public function boot(): void
{
    StateMachine::define(Order::class, 'status', function ($machine) {
        $machine->states(['draft', 'paid', 'shipped', 'delivered', 'canceled'])
            ->initial('draft')
            ->transition('pay')
                ->from('draft')
                ->to('paid')
                ->guard(fn(Order $order) => $order->total > 0)
                ->after(fn(Order $order) => event(new OrderPaid($order)))
            ->transition('ship')
                ->from('paid')
                ->to('shipped')
                ->guard(fn(Order $order) => $order->items()->count() > 0)
            ->transition('deliver')
                ->from('shipped')
                ->to('delivered')
            ->transition('cancel')
                ->from(['draft', 'paid'])
                ->to('canceled');
    });
}

2. Use the Trait in Your Model

use Illuminate\Database\Eloquent\Model;
use Laravel\StateMachine\HasStateMachine;

class Order extends Model
{
    use HasStateMachine;

    protected $fillable = ['status', 'total'];
}

3. Execute Transitions

$order = Order::create(['status' => 'draft', 'total' => 100]);

// Check if transition is allowed
if ($order->canTransition('pay')) {
    $order->transition('pay');
}

// Or directly transition (throws exception if not allowed)
$order->transition('pay');

Configuration

Edit config/state-machine.php:

return [
    'history' => env('STATE_MACHINE_HISTORY', true),
    'strict' => env('STATE_MACHINE_STRICT', false),
    'transactional' => env('STATE_MACHINE_TRANSACTIONAL', true),
    'actor_resolver' => fn() => auth()->id(),
    'concurrency' => [
        'enabled' => env('STATE_MACHINE_CONCURRENCY_ENABLED', true),
        'retry_attempts' => env('STATE_MACHINE_RETRY_ATTEMPTS', 3),
    ],
];

Options

  • history: Enable/disable transition history logging (default: true)
  • strict: Prevent direct state assignment (default: false)
  • transactional: Run transitions in database transactions (default: true)
  • actor_resolver: Callback to determine the user/actor for history (default: auth()->id())
  • concurrency.enabled: Enable optimistic locking (default: true)
  • concurrency.retry_attempts: Number of retry attempts on concurrent conflicts (default: 3)

Advanced Usage

Guards

Guards prevent transitions when conditions aren't met:

->transition('ship')
    ->from('paid')
    ->to('shipped')
    ->guard(fn(Order $order) => $order->items()->count() > 0)
    ->guard(fn(Order $order) => $order->shipping_address !== null)

If any guard returns false or throws an exception, the transition is prevented.

Hooks

Execute code at different stages of a transition:

->transition('pay')
    ->from('draft')
    ->to('paid')
    ->before(function ($order, $from, $to, $transition, $context) {
        // Runs before state change (in transaction)
        logger("About to pay order {$order->id}");
    })
    ->after(function ($order, $from, $to, $transition, $context) {
        // Runs after successful state change (outside transaction)
        event(new OrderPaid($order));
    })
    ->onFailure(function ($order, $from, $to, $transition, $context) {
        // Runs if transition fails
        logger("Failed to pay order {$order->id}");
    })

Hook parameters:

  • $model: The model instance
  • $from: The current state
  • $to: The target state
  • $transition: The transition name
  • $context: Additional context data

Multiple Source States

Transitions can originate from multiple states:

->transition('cancel')
    ->from(['draft', 'paid', 'shipped'])
    ->to('canceled')

Wildcard Transitions

Allow transitions from any state:

->transition('reset')
    ->from('*')
    ->to('draft')

Transition History

Access the transition history:

$history = $order->getStateHistory();
// Returns collection of StateTransition models

// Or use the relationship
$transitions = $order->stateTransitions;

Multiple State Machines

Support multiple independent state machines per model:

// In your model
class Order extends Model
{
    use HasStateMachine;

    protected $stateMachineField = 'status'; // Default field

    // For multiple fields, define machines separately
}

// Define machines for different fields
StateMachine::define(Order::class, 'status', function ($machine) {
    // status state machine
});

StateMachine::define(Order::class, 'payment_status', function ($machine) {
    // payment_status state machine
});

Strict Mode

Enable strict mode to prevent direct state assignment:

STATE_MACHINE_STRICT=true

With strict mode enabled:

// This will throw an exception:
$order->status = 'paid';

// Must use transitions:
$order->transition('pay');

Diagram Generation

Generate visual diagrams of your state machines:

php artisan state-machine:diagram Order
php artisan state-machine:diagram Order --field=status
php artisan state-machine:diagram Order --format=dot --output=order.dot
php artisan state-machine:diagram Order --format=mermaid --output=order.md

Supported formats:

  • mermaid (default): Mermaid flowchart syntax
  • dot: Graphviz DOT format

Testing

The package includes testing helpers:

use Laravel\StateMachine\Tests\TestCase;

class OrderTest extends TestCase
{
    public function test_order_can_be_paid(): void
    {
        $order = Order::create(['status' => 'draft', 'total' => 100]);

        $this->assertCanTransition($order, 'pay');
        $this->assertCannotTransition($order, 'ship');

        $order->transition('pay');

        $this->assertState($order, 'paid');
        $this->assertTransitionHistory($order, ['pay']);
    }
}

Available assertions:

  • assertCanTransition($model, $transition): Assert transition is allowed
  • assertCannotTransition($model, $transition): Assert transition is not allowed
  • assertState($model, $expectedState, $field = null): Assert current state
  • assertTransitionHistory($model, $expectedTransitions, $field = null): Assert transition history

Error Handling

The package throws specific exceptions:

  • StateMachineNotDefinedException: No state machine defined for model/field
  • InvalidTransitionException: Transition not allowed from current state
  • TransitionGuardFailedException: Guard condition failed
  • ConcurrentTransitionException: Concurrent state change detected
use Laravel\StateMachine\Exceptions\InvalidTransitionException;

try {
    $order->transition('invalid');
} catch (InvalidTransitionException $e) {
    // Handle invalid transition
}

Performance Considerations

  • State machine definitions are stored in memory (can be cached in production)
  • Transition execution is O(1) - no reflection in hot paths
  • Optimistic locking uses efficient WHERE clauses
  • History logging is optional and can be disabled

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

The MIT License (MIT). Please see the license file for more information.