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:

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
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
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
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
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()
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
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
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
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
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:
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
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!