• Stars
    star
    523
  • Rank 84,684 (Top 2 %)
  • Language
    PHP
  • License
    MIT License
  • Created almost 4 years ago
  • Updated 3 months ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

State Machines for your Laravel Eloquent models

Latest Version on Packagist Total Downloads

Laravel Eloquent State Machines

Introduction

This package allows you to simplify the transitioning of states an Eloquent model could have by defining the transition logic in specific StateMachine classes. Each class allows you to register validations, hooks and allowed transitions and states making each StateMachine class the only source of truth when moving from a state to the next.

Laravel Eloquent State Machines also allow you to automatically record history of all states a model may have and query this history to take specific actions accordingly.

At its core, this package has been created to provide a simple but powerful API so Laravel developers feel right at home.

Examples

Model with two status fields

$salesOrder->status; // 'pending', 'approved', 'declined' or 'processed'

$salesOrder->fulfillment; // null, 'pending', 'completed'

Transitioning from one state to another

$salesOrder->status()->transitionTo('approved');

$salesOrder->fulfillment()->transitionTo('completed');

//With custom properties
$salesOrder->status()->transitionTo('approved', [
    'comments' => 'Customer has available credit',
]);

//With responsible
$salesOrder->status()->transitionTo('approved', [], $responsible); // auth()->user() by default

Checking available transitions

$salesOrder->status()->canBe('approved');

$salesOrder->status()->canBe('declined');

Checking current state

$salesOrder->status()->is('approved');

$salesOrder->status()->responsible(); // User|null

Checking transitions history

$salesOrder->status()->was('approved');

$salesOrder->status()->timesWas('approved');

$salesOrder->status()->whenWas('approved');

$salesOrder->fulfillment()->snapshowWhen('completed');

$salesOrder->status()->history()->get();

Demo

You can check a demo and examples here

demo

Installation

You can install the package via composer:

composer require asantibanez/laravel-eloquent-state-machines

Next, you must export the package migrations

php artisan vendor:publish --provider="Asantibanez\LaravelEloquentStateMachines\LaravelEloquentStateMachinesServiceProvider" --tag="migrations"

Finally, prepare required database tables

php artisan migrate

Usage

Defining our StateMachine

Imagine we have a SalesOrder model which has a status field for tracking the different stages our sales order can be in the system: REGISTERED, APPROVED, PROCESSED or DECLINED.

We can manage and centralize all of these stages and transitions within a StateMachine class. To define one, we can use the php artisan make:state-machine command.

For example, we can create a StatusStateMachine for our SalesOrder model

php artisan make:state-machine StatusStateMachine

After running the command, we will have a new StateMachine class created in the App\StateMachines directory. The class will have the following code.

use Asantibanez\LaravelEloquentStateMachines\StateMachines\StateMachine;

class StatusStateMachine extends StateMachine
{
    public function recordHistory(): bool
    {
        return false;
    }

    public function transitions(): array
    {
        return [
            //
        ];
    }

    public function defaultState(): ?string
    {
        return null;
    }
}

Inside this class, we can define our states and allowed transitions

public function transitions(): array
{
    return [
        'pending' => ['approved', 'declined'],
        'approved' => ['processed'],
    ];
}

Wildcards are allowed to allow any state change

public function transitions(): array
{
    return [
        '*' => ['approved', 'declined'], // From any to 'approved' or 'declined'
        'approved' => '*', // From 'approved' to any
        '*' => '*', // From any to any
    ];
}

We can define the default/starting state too

public function defaultState(): ?string
{
    return 'pending'; // it can be null too
}

The StateMachine class allows recording each one of the transitions automatically for you. To enable this behavior, we must set recordHistory() to return true;

public function recordHistory(): bool
{
    return true;
}

Registering our StateMachine

Once we have defined our StateMachine, we can register it in our SalesOrder model, in a $stateMachine attribute. Here, we set the bound model field and state machine class that will control it.

use Asantibanez\LaravelEloquentStateMachines\Traits\HasStateMachines;
use App\StateMachines\StatusStateMachine;

class SalesOrder extends Model
{
    Use HasStateMachines;

    public $stateMachines = [
        'status' => StatusStateMachine::class
    ];
}

State Machine Methods

When registering $stateMachines in our model, each state field will have it's own custom method to interact with the state machine and transitioning methods. The HasStateMachines trait defines one method per each field mapped in $stateMachines. Eg.

For

'status' => StatusStateMachine::class,
'fulfillment_status' => FulfillmentStatusStateMachine::class

We will have an accompanying method

status();
fulfillment_status(); // or fulfillmentStatus()

with which we can use to check our current state, history and apply transitions.

Note: the field "status" will be kept intact and in sync with the state machine

Transitioning States

To transition from one state to another, we can use the transitionTo method. Eg:

$salesOrder->status()->transitionTo($to = 'approved');

You can also pass in $customProperties if needed

$salesOrder->status()->transitionTo($to = 'approved', $customProperties = [
    'comments' => 'All ready to go'
]);

A $responsible can be also specified. By default, auth()->user() will be used

$salesOrder->status()->transitionTo(
    $to = 'approved',
    $customProperties = [],
    $responsible = User::first()
);

When applying the transition, the state machine will verify if the state transition is allowed according to the transitions() states we've defined. If the transition is not allowed, a TransitionNotAllowed exception will be thrown.

Querying History

If recordHistory() is set to true in our State Machine, each state transition will be recorded in the package StateHistory model using the state_histories table that was exported when installing the package.

With recordHistory() turned on, we can query the history of states our field has transitioned to. Eg:

$salesOrder->status()->was('approved'); // true or false

$salesOrder->status()->timesWas('approved'); // int

$salesOrder->status()->whenWas('approved'); // ?Carbon

As seen above, we can check whether or not our field has transitioned to one of the queried states.

We can also get the latest snapshot or all snapshots for a given state

$salesOrder->status()->snapshotWhen('approved');

$salesOrder->status()->snapshotsWhen('approved');

The full history of transitioned states is also available

$salesOrder->status()->history()->get();

The history() method returns an Eloquent relationship that can be chained with the following scopes to further down the results.

$salesOrder->status()->history()
    ->from('pending')
    ->to('approved')
    ->withCustomProperty('comments', 'like', '%good%')
    ->get();

Using Query Builder

The HasStateMachines trait introduces a helper method when querying your models based on the state history of each state machine. You can use the whereHas{FIELD_NAME} (eg: whereHasStatus, whereHasFulfillment) to add constraints to your model queries depending on state transitions, responsible and custom properties.

The whereHas{FIELD_NAME} method accepts a closure where you can add the following type of constraints:

  • withTransition($from, $to)
  • transitionedFrom($to)
  • transitionedTo($to)
  • withResponsible($responsible|$id)
  • withCustomProperty($property, $operator, $value)

The $from and $to parameters can be either a status name as a string or an array of status names.

SalesOrder::with()
    ->whereHasStatus(function ($query) {
        $query
            ->withTransition('pending', 'approved')
            ->withResponsible(auth()->id())
        ;
    })
    ->whereHasFulfillment(function ($query) {
        $query
            ->transitionedTo('complete')
        ;
    })
    ->get();

Getting Custom Properties

When applying transitions with custom properties, we can get our registered values using the getCustomProperty($key) method. Eg.

$salesOrder->status()->getCustomProperty('comments');

This method will reach for the custom properties of the current state. You can get custom properties of previous states using the snapshotWhen($state) method.

$salesOrder->status()->snapshotWhen('approved')->getCustomProperty('comments');

Getting Responsible

Similar to custom properties, you can retrieve the $responsible object that applied the state transition.

$salesOrder->status()->responsible();

This method will reach for the responsible of the current state. You can get responsible of previous states using the snapshotWhen($state) method.

$salesOrder->status()->snapshotWhen('approved')->responsible;

Note: responsible can be null if not specified and when the transition happens in a background job. This is because no auth()->user() is available.

Advanced Usage

Tracking Attribute Changes

When recordHistory() is active, model state transitions are recorded in the state_histories table. Each transition record contains information about the attributes that changed during the state transition. You can get information about what has changed via the changedAttributesNames() method. This method will return an array of the attributes names that changed. With these attributes names, you can then use the methods changedAttributeOldValue($attributeName) and changedAttributeNewValue($attributeName) to get the old and new values respectively.

$salesOrder = SalesOrder::create([
    'total' => 100,
]);

$salesOrder->total = 200;

$salesOrder->status()->transitionTo('approved');

$salesOrder->changedAttributesNames(); // ['total']

$salesOrder->changedAttributeOldValue('total'); // 100
$salesOrder->changedAttributeNewValue('total'); // 200

Adding Validations

Before transitioning to a new state, we can add validations that will allow/disallow the transition. To do so, we can override the validatorForTransition($from, $to, $model) method in our state machine class.

This method must return a Validator that will be used to check the transition before applying it. If the validator fails(), a ValidationException is thrown. Eg:

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);
    }
}

In the example above, we are validating that our Sales Order model total is greater than 0 before applying the transition.

Adding Hooks

We can also add custom hooks/callbacks that will be executed before/after a transition is applied. To do so, we must override the beforeTransitionHooks() and afterTransitionHooks() methods in our state machine accordingly.

Both transition hooks methods must return a keyed array with the state as key, and an array of callbacks/closures to be executed.

NOTE: The keys for beforeTransitionHooks() must be the $from states. NOTE: The keys for afterTransitionHooks() must be the $to states.

Example

class StatusStateMachine extends StateMachine
{
    public function beforeTransitionHooks(): array
    {
        return [
            'approved' => [
                function ($to, $model) {
                    // Dispatch some job BEFORE "approved changes to $to"
                },
                function ($to, $model) {
                    // Send mail BEFORE "approved changes to $to"
                },
            ],
        ];
    }

    public function afterTransitionHooks(): array
    {
        return [
            'processed' => [
                function ($from, $model) {
                    // Dispatch some job AFTER "$from transitioned to processed"
                },
                function ($from, $model) {
                    // Send mail AFTER "$from transitioned to processed"
                },
            ],
        ];
    }
}

Postponing Transitions

You can also postpone transitions to other states by using the postponeTransitionTo method. This method accepts the same parameters as transitionTo plus a $when Carbon instance to specify when the transition is to be run.

postponeTransitionTo doesn't apply the transition immediately. Instead, it saves it into a pending_transitions table where it keeps track of all pending transitions for all models.

To enable running this transitions at a later time, you must schedule the PendingTransitionsDispatcher job class into your scheduler to run every one, five or ten minutes.

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

PendingTransitionsDispatcher is responsible for applying the postponed transitions at the specified $when date/time.

You can check if a model has pending transitions for a particular state machine using the hasPendingTransitions() method

$salesOrder->status()->hasPendingTransitions();

Testing

composer test

Changelog

Please see CHANGELOG for more information what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security

If you discover any security related issues, please email [email protected] instead of using the issue tracker.

Credits

License

The MIT License (MIT). Please see License File for more information.

More Repositories

1

livewire-calendar

Laravel Livewire component to show Events in a good looking monthly calendar
PHP
883
star
2

livewire-charts

Neat Livewire Charts for your Laravel projects
PHP
792
star
3

livewire-select

Livewire component for dependant and/or searchable select inputs
PHP
499
star
4

laravel-blade-sortable

Custom Blade components to add sortable/drag-and-drop HTML elements in your apps.
PHP
408
star
5

livewire-status-board

Livewire component to show records according to their current status
PHP
338
star
6

livewire-resource-time-grid

Laravel Livewire component to show Events by time and resource in a good looking grid
PHP
220
star
7

laravel-subscribable-notifications

Laravel Subscribable Notifications
PHP
142
star
8

Patio

A minimalistic Android view widget for selecting multiple images
Java
106
star
9

livewire-charts-demo

Livewire Charts demo app
PHP
69
star
10

Ranger

Android horizontally scrolled DatePicker
Java
57
star
11

livewire-dependant-select-demo

Laravel Livewire demo of multiple selects depending on each other values
PHP
48
star
12

Quota

Quota widget for Android
Java
31
star
13

livewire-calendar-demo

livewire-calendar component demo
PHP
23
star
14

laravel-eloquent-state-machines-demo

Demo repository for Laravel Eloquent State Machines package
PHP
17
star
15

OAuthWebView

WebViews for OAuth Authentication
Java
12
star
16

laravel-blade-sortable-demo

Demos for asantibanez/laravel-blade-sortable
PHP
11
star
17

livewire-wire-model-file-demo

Livewire lifecycle hook example for wire:model in file inputs
PHP
11
star
18

livewire-select-demo

Laravel app showcasing asantibanez/livewire-select component
PHP
10
star
19

practical

Practical ActiveRecord for DynamoDB
JavaScript
10
star
20

livewire-status-board-demo

Livewire Status Board demo app
PHP
7
star
21

Miveo

Vimeo SUPER AWESOME Android App
Java
4
star
22

laravel-inertia-infinite-scroll-demo

Demo for Infinite Scroll Feed in Laravel and InertiaJs
PHP
4
star
23

udacity-spotify-streamer-stage-2

Java
3
star
24

udacity-build-it-bigger

Udacity Build It Bigger Project
Java
2
star
25

udacity-go-ubiquitous

Project 6 of Udacity's Android Nanodegree program
Java
1
star
26

udacity-make-your-app-material

Project 5 of Udacity's Android Nanodegree program.
Java
1
star
27

vue-router-example

Vue.js project that uses Vue-Router
JavaScript
1
star
28

lienzo

Lienzo is a Horizontal/Vertical RecyclerView gallery powered by Picasso
Java
1
star
29

MarkdownViewer

Android Markdown View
Java
1
star
30

android-google-maps-test

Google Maps instructions for Android project
Java
1
star
31

laravel-paypal-server-checkout-test

PHP
1
star
32

livewire-charts-demo-livewire-v3

Livewire Charts Demo in Livewire v3
PHP
1
star
33

golden-layout-vue

Example of Golden Layout + Vue and Vuex integration
Vue
1
star
34

laravel-ecuadorian-taxpayer-validation-rule

Ecuadorian Taxpayer Validation Rule for Laravel
PHP
1
star
35

whatsapp-clone

WhatsApp Web UI clone using TailwindCss
Vue
1
star