Roberto Gallea Blog

TDD implementation of Finite State Machine (FSM) with Laravel

Introduction

This article describes a TDD (Test Driven Development) approach to implement a Finite State Machine (FSM) using PHP+Laravel. However, such method is general and could be used in the programming language of your choice.
A Finite State Machine (FSM) is a model where an object (or entity) can be exactly in one of a finite number of states at any given time. The state is defined by a set of the entity properties. The FSM is represented by a set of states and a set of transitions that bring the model from a state to another Often, developers are required to implement such a model to track the evolution of one of its entities. For example, assume you need to follow the process of a model describing a Request which can be in one of the following states:

  • Draft - a Request which is being filled with its details and has not been submitted yet
  • Submitted - a Request which has been filled and has been submitted for evaluation
  • Rejected - after submission, if the Request is considered unfit, it is rejected and ends its life.
  • Processing - a Request which has been evaluated and considered ready for processing
  • Closed – After being processed, the request is marked as complete and is closed

Allowed transitions could be represented in the following table:
 

From/to Draft Submitted Rejected Processing Closed
Draft - submit() - - -
Submitted - - reject() approve() -
Rejected - - - - -
Processing - - - - close()
Closed - - - - -

Resulting in the following diagram:

FSM diagram

TDD approach

We will proceed using a TDD (Test Driven Development) approach. We write some tests for testing the consistence of states and attributes, and also an unfeasible transition throwing an exception:


Firstly, we will create a new unit test with the command

php artisan make:test FSMTest --unit

and define a new test

class FSMTest extends TestCase
{
    public function testDraftOncreate()
    {
        $r = new Request();
        $this->assertEquals($r->state,'DRAFT');
    }
}

run and fail the test, because the Request class does not exist along with its state attribute.

So, create a Request model using artisan:

php artisan make:model Request

and add a state accessor with mock implementation, returning 'DRAFT' string

class Request extends Model
{
    public function getStateAttribute()
    {
        return 'DRAFT';
    }
}

Run test and success. Then, refactor .

Move state string in constant, return the constant

class Request extends Model
{
    const DRAFT = 'DRAFT';
    
    public function getStateAttribute()
    {
        return Request::DRAFT;
    }
}

so we can now compare the actual state with the value of the constant

 

class FSMTest extends TestCase
{
    public function testDraftOncreate()
    {
        $r = new Request();
        $this->assertEquals($r->state,Request::DRAFT);
    }
}

Run again, success

add test testSubmittedOnSubmit()

public function testSubmittedOnSubmit()
{
    $r = new Request();
    $r->submit();
    $this->assertEquals($r->state,Request::SUBMITTED);
    $this->assertNotNull($r->submitted_at);
}

run and fail

create method submit() setting submitted_at to current time

public function submit()
{
    $this->submitted_at = Carbon::now();
}

run and fail (state is not consistent with the mock state returned by getStateAttribute() string). 

Now I'll go a bit quicker, doing four steps (that should be done one at a time):

refactor class Request, 

  • declare a state attribute, 
  • set state to DRAFT on constructor 
  • set state to SUBMITTED in submit() method 
  • Return $state value in state accessor
     
class Request extends Model
{
    const DRAFT = 'DRAFT';
    const SUBMITTED = 'SUBMITTED';
    
    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
        $this->state = Request::DRAFT;
    }

    public function getStateAttribute()
    {
        return $this->state;
    }

    public function submit()
    {
        $this->submitted_at = Carbon::now();
        $this->state = Request::SUBMITTED;
    }
}

run and success

Repeat similarly for the other states, the result is:

FSMTest.php

class FSMTest extends TestCase
{
    public function testDraftOncreate()
    {
        $r = new Request();
        $this->assertEquals($r->state,Request::DRAFT);
    }

    public function testSubmittedOnSubmit()
    {
        $r = new Request();
        $r->submit();
        $this->assertEquals($r->state,Request::SUBMITTED);
        $this->assertNotNull($r->submitted_at);
    }

    public function testProcessingOnApprove()
    {
        $r = new Request();
        $r->submit();
        $r->approve();
        $this->assertEquals($r->state,Request::PROCESSING);
        $this->assertNotNull($r->approved_at);
    }

    public function testClosedOnClose()
    {
        $r = new Request();
        $r->submit();
        $r->approve();
        $r->close();
        $this->assertEquals($r->state,Request::CLOSED);
        $this->assertNotNull($r->closed_at);
    }

    public function testRejectedOnReject()
    {
        $r = new Request();
        $r->submit();
        $r->reject();
        $this->assertEquals($r->state,Request::REJECTED);
        $this->assertNotNull($r->rejected_at);
    }
}

Request.php

class Request extends Model
{
    const DRAFT = 'DRAFT';
    const SUBMITTED = 'SUBMITTED';
    const PROCESSING = 'PROCESSING';
    const CLOSED = 'CLOSED';
    const REJECTED = 'REJECTED';

    protected $state;

    public function __construct(array $attributes = [])
    {
        parent::__construct($attributes);
        $this->state = Request::DRAFT;
    }

    public function getStateAttribute()
    {
        return $this->state;
    }

    public function submit()
    {
        $this->submitted_at = Carbon::now();
        $this->state = Request::SUBMITTED;
    }

    public function approve()
    {
        $this->approved_at = Carbon::now();
        $this->state = Request::PROCESSING;
    }

    public function close()
    {
        $this->closed_at = Carbon::now();
        $this->state = Request::CLOSED;
    }

    public function reject()
    {
        $this->rejected_at = Carbon::now();
        $this->state = Request::REJECTED;
    }
}

Lastly, test for wrong transition. For example, try to close a submitted but not yet approved request:

public function testExceptionOnInvalidTransition()
{
    $r = new Request();
    $r->submit();
    $this->expectException(\Exception::class);
    $r->close();
}

Run and fail

check transitions, you can close a request only if it is in a PROCESSING state

public function close()
{
    if ($this->state === Request::PROCESSING) {
        $this->closed_at = Carbon::now();
        $this->state = Request::CLOSED;
    } else {
        throw new \Exception('Not feasible transition');
    }
}

Run and success

Note: you must check if requested transition is allowed given the current state, so in every transition method, you have to define a series of if-then-else blocks to embed this logic. This could become very confusing if many states and/or transitions exist.

Conclusion

The described approach is easy to implement, and is suitable for few-states and few-transitions FSMs, but quickly becomes complex to manage when either states or transitions number (or both) increases. For these cases a smarter approach using the State design pattern is preferred. I will describe it in the next post, so stay tuned!