Skip to content

Posts › Development

Development

TDD implementation of Finite State Machine (FSM) with Laravel

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.

6 min read
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:

State 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

php
 1class FSMTest extends TestCase
 2{
 3    public function testDraftOncreate()
 4    {
 5        $r = new Request();
 6        $this->assertEquals($r->state,'DRAFT');
 7    }
 8}

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

php
 1class Request extends Model
 2{
 3    public function getStateAttribute()
 4    {
 5        return 'DRAFT';
 6    }
 7}

Run test and success. Then, refactor .

Move state string in constant, return the constant

php
 1class Request extends Model
 2{
 3    const DRAFT = 'DRAFT';
 4    
 5    public function getStateAttribute()
 6    {
 7        return Request::DRAFT;
 8    }
 9}

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

php
 1class FSMTest extends TestCase
 2{
 3    public function testDraftOncreate()
 4    {
 5        $r = new Request();
 6        $this->assertEquals($r->state,Request::DRAFT);
 7    }
 8}

Run again, success

add test testSubmittedOnSubmit()

php
 1public function testSubmittedOnSubmit()
 2{
 3    $r = new Request();
 4    $r->submit();
 5    $this->assertEquals($r->state,Request::SUBMITTED);
 6    $this->assertNotNull($r->submitted_at);
 7}

run and fail

create method submit() setting submitted_at to current time

php
 1public function submit()
 2{
 3    $this->submitted_at = Carbon::now();
 4}

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
php
 1class Request extends Model
 2{
 3    const DRAFT = 'DRAFT';
 4    const SUBMITTED = 'SUBMITTED';
 5    
 6    public function __construct(array $attributes = [])
 7    {
 8        parent::__construct($attributes);
 9        $this->state = Request::DRAFT;
10    }
11
12    public function getStateAttribute()
13    {
14        return $this->state;
15    }
16
17    public function submit()
18    {
19        $this->submitted_at = Carbon::now();
20        $this->state = Request::SUBMITTED;
21    }
22}

run and success

Repeat similarly for the other states, the result is:

FSMTest.php

php
 1class FSMTest extends TestCase
 2{
 3    public function testDraftOncreate()
 4    {
 5        $r = new Request();
 6        $this->assertEquals($r->state,Request::DRAFT);
 7    }
 8
 9    public function testSubmittedOnSubmit()
10    {
11        $r = new Request();
12        $r->submit();
13        $this->assertEquals($r->state,Request::SUBMITTED);
14        $this->assertNotNull($r->submitted_at);
15    }
16
17    public function testProcessingOnApprove()
18    {
19        $r = new Request();
20        $r->submit();
21        $r->approve();
22        $this->assertEquals($r->state,Request::PROCESSING);
23        $this->assertNotNull($r->approved_at);
24    }
25
26    public function testClosedOnClose()
27    {
28        $r = new Request();
29        $r->submit();
30        $r->approve();
31        $r->close();
32        $this->assertEquals($r->state,Request::CLOSED);
33        $this->assertNotNull($r->closed_at);
34    }
35
36    public function testRejectedOnReject()
37    {
38        $r = new Request();
39        $r->submit();
40        $r->reject();
41        $this->assertEquals($r->state,Request::REJECTED);
42        $this->assertNotNull($r->rejected_at);
43    }
44}

Request.php

php
 1class Request extends Model
 2{
 3    const DRAFT = 'DRAFT';
 4    const SUBMITTED = 'SUBMITTED';
 5    const PROCESSING = 'PROCESSING';
 6    const CLOSED = 'CLOSED';
 7    const REJECTED = 'REJECTED';
 8
 9    protected $state;
10
11    public function __construct(array $attributes = [])
12    {
13        parent::__construct($attributes);
14        $this->state = Request::DRAFT;
15    }
16
17    public function getStateAttribute()
18    {
19        return $this->state;
20    }
21
22    public function submit()
23    {
24        $this->submitted_at = Carbon::now();
25        $this->state = Request::SUBMITTED;
26    }
27
28    public function approve()
29    {
30        $this->approved_at = Carbon::now();
31        $this->state = Request::PROCESSING;
32    }
33
34    public function close()
35    {
36        $this->closed_at = Carbon::now();
37        $this->state = Request::CLOSED;
38    }
39
40    public function reject()
41    {
42        $this->rejected_at = Carbon::now();
43        $this->state = Request::REJECTED;
44    }
45}

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

php
 1public function testExceptionOnInvalidTransition()
 2{
 3    $r = new Request();
 4    $r->submit();
 5    $this->expectException(\Exception::class);
 6    $r->close();
 7}

Run and fail

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

php
 1public function close()
 2{
 3    if ($this->state === Request::PROCESSING) {
 4        $this->closed_at = Carbon::now();
 5        $this->state = Request::CLOSED;
 6    } else {
 7        throw new \Exception('Not feasible transition');
 8    }
 9}

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!