Laravel Eloquent State Machines

repository·master·Indexed 20 days ago

https://github.com/asantibanez/laravel-eloquent-state-machines

A Laravel package for managing complex state transitions for Eloquent models. It centralizes logic in StateMachine classes, provides automatic history tracking in the state_histories table, and supports postponed transitions via a pending_transitions table. Key features include transition validation, before/after hooks, state history querying (was, timesWas, whenWas), and the ability to track attribute changes and custom properties during transitions.

Tokens
4.9K
Snippets
16
Records
17
Agent score
69%

What's inside laravel-eloquent-state-machines

  1. Install Laravel Eloquent State Machines

    master

    Install the package via Composer and then publish the migrations to your application to set up the necessary database tables for state history tracking.

    composer require asantibanez/laravel-eloquent-state-machines
    
    php artisan vendor:publish --provider="Asantibanez\LaravelEloquentStateMachines\LaravelEloquentStateMachinesServiceProvider" --tag="migrations"
    
    php artisan migrate
  2. Define a StateMachine class

    master

    Create a new StateMachine class using the Artisan command: php artisan make:state-machine {Name}.

    In the resulting class, you must implement:

    • transitions(): Returns an array defining allowed state changes. You can use '*' as a wildcard for 'any state'.
    • defaultState(): Returns the starting state for the model (can be null).
    • recordHistory(): Returns true if you want the package to automatically log transitions in the state_histories table.

    Example transition mapping:

    public function transitions(): array
    {
        return [
            'pending' => ['approved', 'declined'],
            'approved' => ['processed'],
        ];
    }
    php artisan make:state-machine StatusStateMachine
  3. Postpone transitions to a future time

    master

    Use postponeTransitionTo to schedule a state transition for a later date instead of applying it immediately. This method saves the transition into a pending_transitions table.

    Requirements

    1. Parameters: Accepts the same parameters as transitionTo, plus a Carbon instance for the $when parameter.
    2. Scheduler: You must schedule the PendingTransitionsDispatcher job in your Laravel scheduler (e.g., every minute) to process these transitions.

    Checking Pending Transitions

    Use hasPendingTransitions() on the state machine instance to check if a model has scheduled transitions.

    Example Scheduler Setup:

    $schedule->job(PendingTransitionsDispatcher::class)->everyMinute();

    Example Usage:

    $salesOrder->status()->postponeTransitionTo('approved', now()->addDays(1));
    
    if ($salesOrder->status()->hasPendingTransitions()) {
        // ...
    }
  4. Register a StateMachine in an Eloquent model

    master

    To attach a StateMachine to a model, use the HasStateMachines trait and define a public $stateMachines array. The keys in this array must match the database column names (fields) on your model.

    use Asantibanez\
    LaravelEloquentStateMachines\Traits\HasStateMachines;
    use App\
    StateMachines\StatusStateMachine;
    
    class SalesOrder extends Model
    {
        use HasStateMachines;
    
        public $stateMachines = [
            'status' => StatusStateMachine::class
        ];
    }
  5. Filter models using `whereHas{FIELD_NAME}`

    master

    The HasStateMachines trait adds dynamic query builder methods to your models. For a field named status, use whereHasStatus. These methods accept a closure to apply constraints on the state history.

    Available constraints inside the closure:

    • withTransition($from, $to): Filter by a specific transition path.
    • transitionedFrom($state): Filter by models that moved from a specific state.
    • transitionedTo($state): Filter by models that moved to a specific state.
    • withResponsible($responsible|$id): Filter by the user/entity that performed the transition.
    • withCustomProperty($property, $operator, $value): Filter by custom properties stored during transition.
    SalesOrder::whereHasStatus(function ($query) {
        $query->withTransition('pending', 'approved')
              ->withResponsible(auth()->id());
    })->get();
    
    SalesOrder::whereHasFulfillment(function ($query) {
        $query->transitionedTo('complete');
    })->get();
  6. Track attribute changes during state transitions

    master

    When recordHistory() is enabled, the state machine records transitions in the state_histories table. You can inspect which model attributes changed during a specific transition using the following methods:

    • changedAttributesNames(): Returns an array of attribute names that were modified during the transition.
    • changedAttributeOldValue($attributeName): Returns the value of the attribute before the transition.
    • changedAttributeNewValue($attributeName): Returns the value of the attribute after the transition.
    $salesOrder = SalesOrder::create(['total' => 100]);
    $salesOrder->total = 200;
    
    $salesOrder->status()->transitionTo('approved');
    
    $salesOrder->changedAttributesNames(); // ['total']
    $salesOrder->changedAttributeOldValue('total'); // 100
    $salesOrder->changedAttributeNewValue('total'); // 200
  7. Transition states using `transitionTo()`

    master

    Once registered, the model provides a method named after the field (e.g., $model->status()) to manage transitions. Use transitionTo($to, $customProperties, $responsible) to move to a new state.

    • $to: The target state name.
    • $customProperties: (Optional) An associative array of data to store with the transition.
    • $responsible: (Optional) The user/entity responsible for the change. Defaults to auth()->user().

    If the transition is not defined in the StateMachine's transitions() method, a TransitionNotAllowed exception is thrown.

    // Basic transition
    $salesOrder->status()->transitionTo('approved');
    
    // Transition with custom properties
    $salesOrder->status()->transitionTo('approved', [
        'comments' => 'Customer has available credit',
    ]);
    
    // Transition with a specific responsible user
    $salesOrder->status()->transitionTo('approved', [], $responsible);
  8. Add transition hooks (before and after)

    master

    You can execute custom logic during the transition lifecycle by overriding beforeTransitionHooks() and afterTransitionHooks() in your state machine class. Both methods must return a keyed array where the key is the state and the value is an array of callbacks/closures.

    • beforeTransitionHooks(): The keys must be the $from states (the state the model is currently in).
    • afterTransitionHooks(): The keys must be the $to states (the state the model is moving into).
    class StatusStateMachine extends StateMachine
    {
        public function beforeTransitionHooks(): array
        {
            return [
                'approved' => [
                    function ($to, $model) {
                        // Logic executed BEFORE "approved changes to $to"
                    },
                ],
            ];
        }
    
        public function afterTransitionHooks(): array
        {
            return [
                'processed' => [
                    function ($from, $model) {
                        // Logic executed AFTER "$from transitioned to processed"
                    },
                ],
            ];
        }
    }
  9. Query state history and snapshots

    master

    If recordHistory() is enabled in your StateMachine, you can query the history of a specific field using these methods on the state machine instance:

    • was($state): Returns true if the model has ever been in $state.
    • timesWas($state): Returns the integer count of how many times the model was in $state.
    • whenWas($state): Returns a Carbon instance of the last time the model was in $state.
    • snapshotWhen($state): Returns a snapshot of the model at the time it was in $state (useful for retrieving historical custom properties or the responsible user).
    • history(): Returns an Eloquent relationship to the StateHistory model, allowing for advanced filtering.
    $salesOrder->status()->was('approved');
    $salesOrder->status()->timesWas('approved');
    $salesOrder->status()->whenWas('approved');
    $salesOrder->status()->snapshotWhen('completed');
    
    // Querying history with scopes
    $salesOrder->status()->history()
        ->from('pending')
        ->to('approved')
        ->withCustomProperty('comments', 'like', '%good%')
        ->get();
  10. Retrieve custom properties and responsible users

    master

    You can access data attached to the current state or historical states:

    • getCustomProperty($key): Retrieves a custom property from the current state.
    • snapshotWhen($state)->getCustomProperty($key): Retrieves a custom property from a previous state.
    • responsible(): Retrieves the user/entity responsible for the current state.
    • snapshotWhen($state)->responsible: Retrieves the user/entity responsible for a previous state.
    // Current state
    $salesOrder->status()->getCustomProperty('comments');
    $salesOrder->status()->responsible();
    
    // Historical state
    $salesOrder->status()->snapshotWhen('approved')->getCustomProperty('comments');
    $salesOrder->status()->snapshotWhen('approved')->responsible;
  11. Add validations to state transitions

    master

    To prevent invalid transitions, override the validatorForTransition($from, $to, $model) method in your StateMachine class. This method must return an instance of Illuminate\Support\Facades\Validator. If the validator fails(), a ValidationException is thrown.

    Note: Always call parent::validatorForTransition($from, $to, $model) at the end of your method to ensure any base validations are still executed.

    use Illuminate\\|Support\\Facades\\Validator as ValidatorFacade;
    
    class StatusStateMachine extends StateMachine
    {
        public function validatorForTransition($from, $to, $model): ?Validator
        {
            if ($from === 'pending' && $to === 'approved') {
                return ValidatorFacade::make([
                    'total' => $model->total,
                ], [
                    'total' => 'gt:0',
                ]);
            }
            
            return parent::validatorForTransition($from, $to, $model);
        }
    }