Skip to content

Instantly share code, notes, and snippets.

@Giacomo92
Forked from iben12/1_Laravel_state-machine.md
Created October 8, 2019 16:10
Show Gist options
  • Save Giacomo92/cf787bfa2b0f040fb4dbea5042063385 to your computer and use it in GitHub Desktop.
Save Giacomo92/cf787bfa2b0f040fb4dbea5042063385 to your computer and use it in GitHub Desktop.
Laravel: State-machine on Eloquent Model

Implementing State Machine On Eloquent Model*

* Update (12.09.2017): I have improved the trait so that it can be used with objects other than Eloquent Models.

Some days ago I came across a task where I needed to implement managable state for an Eloquent model. This is a common task, actually there is a mathematical model called "Finite-state Machine". The concept is that the state machine (SM) "can be in exactly one of the finite number of states at any given time". Also changing from one state to another (called transition) depends on fulfilling the conditions defined by its configuration.

Practically this means you define each state that the SM can be in and the possible transitions. To define a transition you set the states on which the transition can be applied (initial conditions) and the only state in which the SM should be after the transition.

That's the theory, let's get to the work.

Dependency setup

Since SM is a common task, we can choose existing implementations using composer packages. We are on Laravel, so I searched for Laravel SM packages and found the sebdesign/laravel-state-machine package, which is a Laravel service provider for the winzou/state-machine package.

So let's require that:

$ composer require sebdesign/laravel-state-machine

Then we have to register the provider and facade in the configuration:

// config/app.php

'providers' => [
    Sebdesign\SM\ServiceProvider::class,
],

'aliases' => [
    'StateMachine' => Sebdesign\SM\Facade::class,
],

and publish the confiuration file to config/state-machine.php:

$ php artisan vendor:publish --provider="Sebdesign\SM\ServiceProvider"

Now we can instantiate SMs by calling the get method on SM\FactoryInterface like this:

$sm = app(SM\FactoryInterface::class)->get($object,$graph);

where $object is the entity whose state we want to manage and $graph is the configuration of the possible states and transitions to be used.

Configuration and migrations

Let's assume we have a model in our app that needs managed state. A typical case for this is an Order. It should be in exactly one state at any time and there are strict rules from which state to what state it can get (ex. a shipped order cannot be cancelled, but it can get delivered and from there it can be returned).

We will define the configuration first. This also helps us to clarify what want to implement. Opening config/state-machine.php you will see an example configuration provided by the package, named graphA. The config returns an array of graphs. You can get rid of the predefined graph or leave it as is for future reference.

Let's create a new graph for our Order model:

// config/state_machine.php

return [
    'order' => [
        'class' => App\Order::class,
        'property_path' => 'last_state',
        'states' => [
            'new',
            'processed',
            'cancelled',
            'shipped',
            'delivered',
            'returned'
        ],
        'transitions' => [
            'process' => [
                'from' => ['new'],
                'to' => 'processed'
            ],
            'cancel' => [
                'from' => ['new','processed'],
                'to' => 'cancelled'
            ],
            'ship' => [
                'from' => ['processed'],
                'to' => 'shipped'
            ],
            'deliver' => [
                'from' => ['shipped'],
                'to' => 'delivered'
            ],
            'return' => [
                'from' => ['delivered'],
                'to' => 'returned'
            ]
        ]
    ],

    //...
]

As I assumed, we have this App\Order::class and its migration already, but we defined the property_path to be last_state in the above config. This means SM will look for a last_state property on the object and use that for storing the state. We need to add that. Create the migration first:

$ php artisan make:migration add_last_state_to_orders

then edit it:

// database/migrations/yyyy_mm_dd_hhmmss_add_last_state_to_orders_table.php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class AddLastStateToOrdersTable extends Migration
{
    public function up()
    {
        Schema::table('orders', function($table) {
            $table->string('last_state')->default('new');
        });
    }

    public function down()
    {
        Schema::table('oders', function($table) {
            $table->dropColumn('last_state');
        });
    }
}

We also want to store the state history for our orders, so that we can see who and when initiated transitions on a given entity. As we are at it, let's make a model and migration for that as well:

$ php artisan make:model OrderState -m

This will create a model class in app/OrderState.php and a migration. Edit the migration first:

// database/migrations/yyyy_mm_dd_hhmmss_create_order_states_table.php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateOrderStatesTable extends Migration
{
    public function up()
    {
        Schema::create('order_states', function (Blueprint $table) {
            $table->increments('id');
            $table->integer('order_id');
            $table->string('transition');
            $table->string('to');
            $table->integer('user_id');
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('order_states');
    }
}

then run

$ php artisan migrate

Now let's see the OrderState model! We need to set up relations to the order and the user:

// app/OrderState.php

namespace App;

use Illuminate\Database\Eloquent\Model;

class OrderState extends Model
{
    protected $fillable = ['transition','from','user_id','order_id','to'];

    public function order() {
        return $this->belongsTo('App\Order');
    }

    public function user() {
        return $this->belongsTo('App\User');
    }
}

That's all, now we can start implementing the SM.

The Statable Trait

First I just wrote some methods in the Order model to manage the SM, but then I thought: this feels like a drop-in feature, that can be added to any model. And this calls for a Trait.

So let' create the Statable trait:

// app/Traits/Statable.php

namespace App\Traits

trait Statable
{

}

When I first added the SM directly in the model I used its __constructor() method to instantiate the SM and store it in a property. However calling the __constructor() in a trait doesn't seem to be a good idea, so we need another approach to make sure the SM gets set up and stored, but instantiated only once. Let's create a stateMachine method for this purpose:

// app/Traits/Statable.php

namespace App\Traits

use SM\Factory\FactoryInterface;

trait Statable
{
    /**
     * @var StateMachine $stateMachine
     */
    protected $stateMachine;

    public function stateMachine()
    {
        if (!$this->stateMachine) {
            $this->stateMachine = app(FactoryInterface::class)->get($this, self::SM_CONFIG);
        }
        return $this->stateMachine;
    }
}

Don't forget to import the SM's FactoryInterface! Here we check if the model has a SM already and if not we get one from the factory using the model object and the graph as parameters. The graph should be specified in the model class as the SM_CONFIG constant. I made the method public so that we can interact with the SM from the model in a fluent way (like $order->stateMachine()->getState()), however we will implement convenience methods to access some interactions with short syntax.

First will be the stateIs() method.

// app/Traits/Statable.php

public function stateIs()
{
    return $this->stateMachine()->getState();
}

Now we can call this method on the model to get the current state:

$currentState = $order->stateIs();

Next we need a method for applying a transition, let it be transition():

// app/Traits/Statable.php

public function transition($transition)
{
    return $this->stateMachine()->apply($transition);
}

so we can use it like:

$order->transition('process');

Also we have a method on the SM that helps determine if a transition can be applied on the current state. We will also expose this directly on the model with the transitionAllowed() method that wraps the SM's can() method:

// app/Traits/Statable.php

public function transitionAllowed($transition)
{
    return $this->stateMachine()->can($transition);
}

One last thing we need is to set up is the state history relation. Since this is tightly coupled with the SM, we can create its method in this trait:

// app/Traits/Statable.php

public function history()
{
    return $this->hasMany(self::HISTORY_MODEL['name']);
}

As you can see the related model class will be configured on the model itself by the HISTORY_MODEL constant along with SM_CONFIG.

We are ready with this trait, now we can use it in the model.

The Order model

Basically now we just let our model use the Statable trait and define the config needed by the trait.

// app/Order.php

namespace App;

use Traits\Statable;
use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    use Statable;
    const HISTORY_MODEL = [
        'name' => 'App\OrderState' // the related model to store the history
    ];
    const SM_CONFIG = 'order'; // the SM graph to use

    // other relations and methods of the model

}

Now we can manage the state of the model like this:

$order = App\Order::first();

try {
    $order->transition('process');
} catch (Exception $e) {
    // if the transition cannot be applied on the current state or it does not exist
    // SM will throw an SMException instance
    // we can handle it here
}

$order->save();

Or alternatively we can check if the transition can be applied first:

$order = App\Order::first();

if ($order->transitionAllowed('process') {
    $order->transition('process');
    $order->save();
} else {
    // handle rejection
}

This will now update the last_state property of the model and by calling the save method it also persists it in the DB. However we do not store the history yet.

Storing State History

Every time our model changes state through a transition of the SM we need to add a row to the order_states table saving the transition, the state we got in, the user who initated it and when this happened. Writing this manually every time you apply a transition can become tedious, we need something better.

Fortunately our SM fires Events if you interact with it and we can use them to call the tasks we have to do every time. SM has the following events:

  • TEST_TRANSITION fired when we call the can() method, or our wrapper for that: transitionAllowed()
  • PRE_TRANSITION fired before any transition
  • POST_TRANSITION fired after any transition

We will use the POST_TRANSITION event and set up a listener for that. First we register our listener:

// app/Providers/EventServiceProvider.php

protected $listen = [
    SMEvents::POST_TRANSITION => [
        'App\Listeners\StateHistoryManager@postTransition',
    ],
];

Let's create that class:

// app/Listeners/StateHistoryManager.php

namespace App\Listeners;

use SM\Event\TransitionEvent;

class StateHistroyManager
{
    public function postTransition(TransitionEvent $event)
    {
        $sm = $event->getStateMachine();
        $model = $sm->getObject();

        $model->addHistoryLine([
            "transition" => $event->getTransition(),
            "to" => $sm->getState()
        ]);
    }
}

Since the Event contains the SM instance and that contains the model instance our job is easy. We get the model, we save it and create a history relation on it that is filled up with the data by calling addHistoryLine. So, from now on we don't even have to bother with saving the model after a state change. Let's make the method:

// app/Traits/Statable.php
public function addHistoryLine(array $transitionData)
{
    $this->save();
    
    $transitionData['user_id'] = auth()->id();

    return $this->history()->create($transitionData);
}

NOTE: here we assumed that we have a logged in user. It makes sense to assume a state change can be triggered by an autheticated user only (maybe with proper role), so it is on you to ensure this. You may also wonder why don't we just make this in the listener, but you will find out later.

Let's use it!

Now that everything is ready we can use our SM. Here is an example with a route closure:

// routes/web.php
// the URL would be like: /order/8/process
Route::get('/order/{order}/{transition}', function (App\Order $order, $transition)
{ 
    // make sure you have autheticated user by route middleware or Auth check

    try {
        $order->transition($transition);
    } catch(Exception $e) {
        return abort(500, $e->getMessage());
    }
    return $order->history()->get();
});

The response will be something like the following json if the order state was new:

[
    {
        "id": 1,
        "order_id": "8",
        "transition": "process",
        "to": "processed",
        "user_id": 1,
        "created_at": "2017-02-02 15:55:01",
        "updated_at": "2017-02-02 15:55:01"
    }
]

Taking it a step further

You may have noticed that at some points (configuring HISTORY_MODEL and with the addHistoryLine() method) we could be more simple or specific if we're using Eloquent\Models only anyway. However, with a little addition to our trait we would be able to use it on any type of object, rather than just Models.

Fisrt we need a method to determine whether we are working with an Eloquent\Model:

// app/Traits/Statable.php

protected function isEloquent()
{
    return $this instanceof \Illuminate\Database\Eloquent\Model;
}

Now we can improve the history and addHistoryLine methods to have non-eloquent-compatible implementations as well. First history() will return either a relation or the HISTORY_MODEL filtered for the actual object.

// app/Traits/Statable.php

public function history() {
    if ($this->isEloquent()) {
        return $this->hasMany(self::HISTORY_MODEL['name']);
    }

    /** @var \Eloquent $model */
    $model = app(self::HISTORY_MODEL['name']);
    return $model->where(self::HISTORY_MODEL['foreign_key'], $this->{self::PRIMARY_KEY}); // maybe use scope here
}

For this we have to configure the HISTORY_MODEL['foreign_key'] and PRIMARY_KEY on non-eloquent objects which will be used to handle the history.

The addHistoryLine method would look like this:

// app/Traits/Statable.php
public function addHistoryLine(array $transitionData)
{
    $transitionData['user_id'] = auth()->id();

    if ($this->isEloquent()) {
        $this->save();
        return $this->history()->create($transitionData);
    }

    $transitionData[self::HISTORY_MODEL['foreign_key']] = $this->{self::PRIMARY_KEY};
    /** @var \Eloquent $model */
    $model = app(self::HISTORY_MODEL['name']);

    return $model->create($transitionData);
}

IMPORTANT: in case of non-eloquent objects you have to handle the persisting of the object itself with its changed last_state property.

Testing

We can test the model's statable behaviour with PHPUnit. Let's create the test:

$ php artisan make:test StatableOrderTest --unit

This will create our test class in the file tests/Unit/StatableOrderTest.php. Besides the default imports we will need the following (if you use an IDE you can import these as you use them in the tests):

  • App\Order
  • App\User
  • SM\SMException
  • Illuminate\Support\Facades\Auth

Let us create a setUp() method to get an Order instance and log in a User for the interactions:

namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Order;
use App\User;
use SM\SMException;
use Illuminate\Support\Facades\Auth;
class StatebleOrderTest extends TestCase
{
    protected $order;
    protected function setUp()
    {
        parent::setup();
        $this->order = factory(Order::class)->create();
        Auth::login(factory(User::class)->create());
    }

This will run before every test and give us a fresh Order instance to work with. However, to make this possible, we need to define the factory for creating an Order in database/factories/ModelFactory.php. It can be something like this:

// database/factories/ModelFactory.php

$factory->define(App\Order::class, function (Faker\Generator $faker) {
    return [
        // all non-nullable fields
        'last_state' => 'new'
    ];
});

Of course you have to fill all your non-nullable fields, otherwise you will see some SQLExceptions when the model is saved.

Our first test will make sure we can instantiate a SM on our Order model:

// tests/StatableOrderTest.php

public function testCreation()
{
    $this->assertInstanceOf('SM\StateMachine\StateMachine', $this->order->stateMachine());
}

This test will fail if we mess up anything with the dependencies or try to use invalid SM configuration.

Our second test will check if the stateIs() method returns the current state:

// tests/StatableOrderTest.php

public function testGetState()
{
    $this->assertEquals('new', $this->order->stateIs());
}

Next up, let's test if we can apply a transition:

// tests/StatableOrderTest.php

    public function testTransitionState()
    {
        $this->order->transition('process');

        // let's refresh the model to see if the state was really persisted
        $this->order = $this->order->fresh();

        $this->assertEquals('processed', $this->order->stateIs());
        $this->assertEquals(1,$this->order->history()->count());

        $this->order->transition('ship');

        $this->order = $this->order->fresh();

        $this->assertEquals('shipped', $this->order->stateIs());
        $this->assertEquals(2,$this->order->history()->count());
    }

Here we make two transitions and check if the state has changed and a new history line is added for each transition.

Our next test will check the transitionAllowed() method:

// tests/StatableOrderTest.php

    public function testTransitionAllowed()
    {
        $this->assertTrue($this->order->transitionAllowed('process'));
        $this->assertFalse($this->order->transitionAllowed('ship'));
    }

The last one will try to apply an invalid transition and expects the SM to throw an Exception:

// tests/StatableOrderTest.php

    public function testInvalidTransition()
    {
        $this->expectException(SMException::class);

        $this->order->transition('ship');
    }

This basically covers the main features, but you can extend it as you like.

Conclusion

We can use the Statable trait on any object, we just need to create a state history model drop-in the trait and configure object class.

<?php
// database/migrations/yyyy_mm_dd_hhmmss_add_last_state_to_orders_table.php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddLastStateToOrdersTable extends Migration
{
public function up()
{
Schema::table('orders', function($table) {
$table->string('last_state');
});
}
public function down()
{
Schema::table('oders', function($table) {
$table->dropColumn('last_state');
});
}
}
<?php
// database/migrations/yyyy_mm_dd_hhmmss_create_order_states_table.php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateOrderStatesTable extends Migration
{
public function up()
{
Schema::create('order_states', function (Blueprint $table) {
$table->increments('id');
$table->string('order_id');
$table->string('transition');
$table->string('to');
$table->integer('user_id');
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('order_states');
}
}
<?php
// app/Order.php
namespace App;
use Traits\Statable;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use Statable;
const HISTORY_MODEL = [
'name' => 'App\OrderState' // the related model to store the history
];
const SM_CONFIG = 'order'; // the SM graph to use
// other relations and methods of the model
}
<?php
// app/OrderState.php
namespace App;
use Illuminate\Database\Eloquent\Model;
class OrderState extends Model
{
protected $fillable = ['transition','from','user_id','order_id','to'];
public function order() {
return $this->belongsTo('App\Order');
}
public function user() {
return $this->belongsTo('App\User');
}
}
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Model;
use SM\Factory\FactoryInterface;
use SM\StateMachine\StateMachine;
trait Statable
{
/**
* @var StateMachine $stateMachine
*/
protected $stateMachine;
public function history() {
if ($this->isEloquent()) {
return $this->hasMany(self::HISTORY_MODEL['name']);
}
/** @var \Eloquent $model */
$model = app(self::HISTORY_MODEL['name']);
return $model->where(self::HISTORY_MODEL['foreign_key'], $this->{self::PRIMARY_KEY});
}
public function addHistoryLine(array $transitionData)
{
$transitionData['user_id'] = auth()->id();
if ($this->isEloquent()) {
$this->save();
return $this->history()->create($transitionData);
}
$transitionData[self::HISTORY_MODEL['foreign_key']] = $this->{self::PRIMARY_KEY};
/** @var \Eloquent $model */
$model = app(self::HISTORY_MODEL['name']);
return $model->create($transitionData);
}
public function stateIs()
{
return $this->StateMachine()->getState();
}
public function transition($transition)
{
return $this->stateMachine()->apply($transition);
}
public function transitionAllowed($transition)
{
return $this->StateMachine()->can($transition);
}
/**
* @return StateMachine
*/
public function stateMachine()
{
if (!$this->stateMachine) {
$this->stateMachine = app(FactoryInterface::class)->get($this, self::SM_CONFIG);
}
return $this->stateMachine;
}
public function isEloquent()
{
return $this instanceof Model;
}
}
<?php
namespace Tests\Unit;
use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Order;
use App\User;
use SM\SMException;
use Illuminate\Support\Facades\Auth;
class StatebleOrderTest extends TestCase
{
protected $order;
protected function setUp()
{
parent::setup();
$this->order = factory(Order::class)->create();
Auth::login(factory(User::class)->create());
}
public function testCreation()
{
$this->assertInstanceOf('SM\StateMachine\StateMachine', $this->order->stateMachine());
}
public function testGetState()
{
$this->assertEquals('new', $this->order->stateIs());
}
public function testTransitionState()
{
$this->order->transition('process');
// let's refresh the model to see if the state was really persisted
$this->order = $this->order->fresh();
$this->assertEquals('processed', $this->order->stateIs());
$this->assertEquals(1,$this->order->history()->count());
$this->order->transition('ship');
$this->order = $this->order->fresh();
$this->assertEquals('shipped', $this->order->stateIs());
$this->assertEquals(2,$this->order->history()->count());
}
public function testTransitionAllowed()
{
$this->assertTrue($this->order->transitionAllowed('process'));
$this->assertFalse($this->order->transitionAllowed('ship'));
}
public function testInvalidTransition()
{
$this->expectException(SMException::class);
$this->order->transition('ship');
}
}
<?php
// config/state_machine.php
return [
'order' => [
'class' => App\Order::class,
'property_path' => 'last_state',
'states' => [
'new',
'processed',
'cancelled',
'shipped',
'delivered',
'returned'
],
'transitions' => [
'process' => [
'from' => ['new'],
'to' => 'processed'
],
'cancel' => [
'from' => ['new','processed'],
'to' => 'cancelled'
],
'ship' => [
'from' => ['processed'],
'to' => 'shipped'
],
'deliver' => [
'from' => ['shipped'],
'to' => 'delivered'
],
'return' => [
'from' => ['delivered'],
'to' => 'returned'
]
]
],
//...
]
<?php
// app/Listeners/StateHistoryManager.php
namespace App\Listeners;
use SM\Event\TransitionEvent;
class StateHistroyManager
{
public function postTransition(TransitionEvent $event)
{
$sm = $event->getStateMachine();
$model = $sm->getObject();
$model->history()->create([
"transition" => $event->getTransition(),
"to" => $sm->getState(),
"user_id" => auth()->id()
]);
$model->save();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment