* 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.
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.
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.
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.
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.
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 thecan()
method, or our wrapper for that:transitionAllowed()
PRE_TRANSITION
fired before any transitionPOST_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.
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"
}
]
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\Model
s 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.
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.
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.
Thank you for this tutorial,
I'd really really need this, but unfortunately I couldn't completely understand how it works, would you please bring all of this example in a sample laravel repo in github? so that I can play with it and fully understand how it works
thank you again friend