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.

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
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!