A Laravel 11+ package that provides a fluent state machine implementation without requiring Eloquent models. Inspired by Spatie's laravel-model-states but designed to be model-agnostic.
composer require crumbls/state-machine<?php
use Crumbls\StateMachine\State;
use Crumbls\StateMachine\StateConfig;
abstract class OrderState extends State
{
abstract public function color(): string;
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Processing::class)
->allowTransition(Processing::class, Shipped::class)
->allowTransition(Processing::class, Cancelled::class)
->allowTransition(Shipped::class, Delivered::class)
->allowTransition(Pending::class, Cancelled::class);
}
}
class Pending extends OrderState
{
public function color(): string
{
return 'yellow';
}
}
class Processing extends OrderState
{
public function color(): string
{
return 'blue';
}
}
// ... other state classesuse Crumbls\StateMachine\StateMachine;
// Create a new state machine
$machine = StateMachine::make(OrderState::class);
// Check current state
$machine->is(Pending::class); // true
// Check if transition is allowed
$machine->canTransitionTo(Processing::class); // true
// Transition to new state
$machine->transitionTo(Processing::class);
// Access state methods
$machine->getCurrentState()->color(); // 'blue'// Create with initial context
$machine = StateMachine::make(OrderState::class, [
'order_id' => 123,
'user_id' => 456
]);
// Add context during transition
$machine->transitionTo(Processing::class, [
'processed_at' => now(),
'processor_id' => auth()->id()
]);
// Access context
$context = $machine->getContext();use Crumbls\StateMachine\StateMachineManager;
$manager = app(StateMachineManager::class);
// Create named state machine
$machine = $manager->create('order-123', OrderState::class);
// Retrieve later
$machine = $manager->get('order-123');
// Or use facade
$machine = StateMachine::create('order-123', OrderState::class);Add Laravel-style middleware to state transitions for security, validation, and performance monitoring:
use Crumbls\StateMachine\Middleware\RateLimitMiddleware;
use Crumbls\StateMachine\Middleware\ValidationMiddleware;
use Crumbls\StateMachine\Middleware\TimingMiddleware;
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Processing::class)
->middleware([
// Rate limiting: max 5 attempts per minute
RateLimitMiddleware::perMinutes(5, 1),
// Validation: ensure required context
ValidationMiddleware::rules([
'user_id' => 'required|integer',
'payment_confirmed' => 'required|boolean'
]),
// Performance monitoring
TimingMiddleware::maxExecutionTime(2000) // 2 seconds max
]);
}Available Middleware:
RateLimitMiddleware: Prevents too many transition attemptsValidationMiddleware: Validates context data using Laravel validation rulesThrottleMiddleware: Temporarily blocks transitions after failuresTimingMiddleware: Monitors and limits execution timeLoggingMiddleware: Comprehensive transition logging
Custom Middleware:
Create your own middleware using Laravel's standard pattern:
class CustomAuthMiddleware
{
public function handle(StateTransitionRequest $request, Closure $next)
{
if (!auth()->check()) {
throw new UnauthorizedException('Authentication required');
}
// Add user info to context
$request->mergeContext(['authenticated_user' => auth()->id()]);
return $next($request);
}
}
// Use in config
->middleware([CustomAuthMiddleware::class])Add conditional logic to transitions:
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Processing::class)
->guard(Pending::class, Processing::class, function ($state, $context) {
return isset($context['payment_confirmed']) && $context['payment_confirmed'];
});
}Add hooks for state transitions:
public static function config(): StateConfig
{
return parent::config()
->default(Pending::class)
->allowTransition(Pending::class, Processing::class)
->onTransition(Pending::class, Processing::class, function ($state, $context) {
// Log transition
Log::info('Order processing started', $context);
})
->onEnter(Processing::class, function ($state, $context) {
// Send notification
Notification::send($context['user'], new OrderProcessingNotification());
});
}The package fires Laravel events on state transitions:
use Crumbls\StateMachine\Events\StateTransitioned;
use Crumbls\StateMachine\Events\AsyncTransitionCompleted;
use Crumbls\StateMachine\Events\AsyncTransitionFailed;
Event::listen(StateTransitioned::class, function ($event) {
// $event->stateMachine
// $event->fromState
// $event->toState
// $event->context
});
Event::listen(AsyncTransitionCompleted::class, function ($event) {
// $event->stateMachine
// $event->fromState
// $event->toState
// $event->identifier
// $event->context
});Process state transitions in the background using Laravel queues:
// Queue a state transition
$job = $machine->transitionToAsync(Processing::class, ['note' => 'async processing']);
// With custom queue and delay
$job = $machine->transitionToAsync(
Processing::class,
['note' => 'processing'],
'order-123', // identifier
'state-transitions', // queue name
60 // delay in seconds
);Automatically continue to the next state when there's only one option:
$job = $machine->transitionToAsyncWithContinuation(
Processing::class,
['note' => 'will auto-continue'],
'order-123',
'state-transitions',
0,
function ($machine, $from, $to) {
// Success callback
Log::info("Transitioned from {$from} to {$to}");
},
function ($exception, $toState, $context) {
// Failure callback
Log::error("Failed to transition to {$toState}: " . $exception->getMessage());
}
);Use the HasStateMachine trait for seamless Eloquent model integration:
use Crumbls\StateMachine\Traits\HasStateMachine;
class Order extends Model
{
use HasStateMachine;
protected $fillable = ['state_machine_data', 'customer_id'];
public function getStateMachineClass(): string
{
return OrderState::class;
}
}
// Usage
$order = Order::create(['customer_id' => 123]);
// Sync transition
$order->transitionTo(Processing::class);
// Async transition
$order->transitionToAsync(Processing::class, ['note' => 'processing async']);
// Check state
$order->isInState(Processing::class); // true
$order->getCurrentState()->color(); // 'blue'
// With model-based jobs
class ProcessOrderJob implements ShouldQueue
{
public function __construct(public Order $order) {}
public function handle()
{
// The state machine data is automatically saved/loaded
$this->order->transitionTo(Shipped::class);
}
}Run the test suite:
composer test- PHP 8.2+
- Laravel 11+
MIT License