Roberto Gallea Blog

State pattern implementation of finite state machine (FSM) with Laravel

In the previous article TDD IMPLEMENTATION OF FINITE STATE MACHINE (FSM) WITH LARAVEL I discussed a basic approach for implement a Finite State Machine (FSM) on a Laravel model. In this article I will further discuss the topic by applying a more engineered approach. It involves the usage of the State pattern, which encapsulates the model state inside an object. The state object is also delegate to process the transition between states.

 

Motivation

The basic approach followed in the previous article is easy to implement, and is suitable for few-states and few-transitions FSMs. However, it quickly becomes complex to manage when either states or transitions number (or both) increases.

 

Background

The example is the following:

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

State pattern

[From Wikipedia, link] The state pattern is a behavioral software design pattern that implements a state machine in an object-oriented way. With the state pattern, a state machine is implemented by implementing each individual state as a derived class of the state pattern interface, and implementing state transitions by invoking methods defined by the pattern's superclass.
Thus, the state-specific behavior is not implemented by the Context class

FSM diagram

By Vanderjoe - Own work, CC BY-SA 4.0, Link

 

Pattern application

Pattern implementation is reported in the following diagram:

FSM diagram

Where Request is the context, RequestState is the common interface for all of the interfaces, defining a method for each of the possible transitions (submit(), reject(), approve(), close()) and the constructor, which is actually used for setting the context. Additionally, Request has two further methods, the accessor getStateAttribute() and the mutator setStateAttribute() used to read and write the state respectively.


Request.php

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

    protected $state;

    public function __construct(array $attributes = [])
    {
        $this->state = new DraftState($this);
    }

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

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

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

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

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

    public function setStateAttribute($value)
    {
        $this->state = $value;
    }
}

RequestState.php

interface RequestState
{
    public function __construct($context);
    public function submit();
    public function reject();
    public function approve();
    public function close();
    public function __toString();
}

The interface is then implemented by each of the available states. The constructor simply set a reference to the owner model.

class DraftState implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

    …
}

If the transition is allowed, its method definition is responsible of updating some attributes and/or do other things (for example launching events), and finally set the new request state. If the transition is not allowed an Exception is raised. For example the method submit() in DraftState does the following:

  • Set the submission timestamp
  • Set the new state as Submitted
     
class DraftState implements RequestState
{
   …
    public function submit()
    {
        $this->context->submitted_at = Carbon::now();
        $this->context->state = new SubmittedState($this->context);
    }
}

While the close() method, which is related to an unallowed transition, raises an exception:

class DraftState implements RequestState
{
   …
    public function close()
    {
        throw new \Exception('Not allowed transition');
    }
}

The complete implementation of all the five State classes is:

DraftState.php

class DraftState implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

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

    public function reject()
    {
        throw new \Exception('Not allowed transition');
    }

    public function approve()
    {
        throw new \Exception('Not allowed transition');
    }

    public function close()
    {
        throw new \Exception('Not allowed transition');
    }

    public function __toString()
    {
        return Request::DRAFT;
    }
}

ProcessingState.php

class SubmittedState  implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

    public function submit()
    {
        throw new \Exception('Not allowed tranistion');
    }

    public function reject()
    {
        $this->context->rejected_at = Carbon::now();
        $this->context->state = new RejectedState($this->context);
    }

    public function approve()
    {
        $this->context->approved_at = Carbon::now();
        $this->context->state = new ProcessingState($this->context);
    }

    public function close()
    {
        throw new \Exception('Not allowed transition');
    }

    public function __toString()
    {
        return Request::SUBMITTED;
    }
}

RejectedState.php

class RejectedState  implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

    public function submit()
    {
        throw new \Exception('Not allowed transition');
    }

    public function reject()
    {
        throw new \Exception('Not allowed transition');
    }

    public function approve()
    {
        throw new \Exception('Not allowed transition');
    }

    public function close()
    {
        throw new \Exception('Not allowed transition');
    }

    public function __toString()
    {
        return Request::REJECTED;
    }
}

ProcessingState.php

class ProcessingState  implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

    public function submit()
    {
        throw new \Exception('Not allowed transition');
    }

    public function reject()
    {
        throw new \Exception('Not allowed transition');
    }

    public function approve()
    {
        throw new \Exception('Not allowed transition');
    }

    public function close()
    {
        $this->context->closed_at = Carbon::now();
        $this->context->state = new ClosedState($this->context);
    }

    public function __toString()
    {
        return Request::PROCESSING;
    }
}

ClosedState.php

class ClosedState implements RequestState
{
    protected $context;

    public function __construct($context)
    {
        $this->context = $context;
    }

    public function submit()
    {
        throw new \Exception('Not allowed transition');
    }

    public function reject()
    {
        throw new \Exception('Not allowed transition');
    }

    public function approve()
    {
        throw new \Exception('Not allowed transition');
    }

    public function close()
    {
        throw new \Exception('Not allowed transition');
    }

    public function __toString()
    {
        return Request::CLOSED;
    }
}

You can use the same tests in the previous article to prove the functionality of the code. Tests are reported below for your convenience:

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);
    }
}