Finite State Machine implementation
Add package to your project:
composer require sofa/state-machine
State machine helps you eliminate switch
and/or if/else
statements in your code to determine available actions in given state.
Let's use a naive example from a Laravel's Blade view template and underlying Eloquent Order
model:
@foreach($orders as $order)
{{ $order->reference }} status: {{ $order->status }}
@if($order->status === 'new')
<button>start processing</button>
@elseif($order->status === 'awaiting_payment')
<button>record payment</button>
@elseif($order->status === 'awaiting_shipment')
<button>save tracking number</button>
@elseif($order->status === 'in_delivery')
<button>record delivery</button>
<button>open claim</button>
@elseif($order->status === 'complete')
<button>open claim</button>
@elseif($order->status === 'processing_claim')
<button>refund</button>
<button>close claim</button>
@endif
@endforeach
This quickly gets out of hand, especially when a new status is introduced or the processing order changes.
To streamline it, we can implement state machine for the Order entity:
-
implement interface on the
Order
modelclass Order extends Model implements \Sofa\StateMachine\StateMachineInterface { //... public function getCurrentState() : string { return $this->status; } public function setState(string $state) : void { $this->status = $state; $this->save(); } }
-
define available transitions and prepare data for the template:
$transitions = [ Transition::make(/*from_state*/ 'new', /*action*/ 'start processing', /*to_state*/ 'awaiting_payment'), Transition::make('awaiting_payment', 'record payment', 'awaiting_shipment'), Transition::make('awaiting_shipment', 'save tracking number', 'in_delivery'), Transition::make('in_delivery', 'record delivery', 'complete'), Transition::make('in_delivery', 'open claim', 'processing_claim'), Transition::make('complete', 'open claim', 'processing_claim'), Transition::make('processing_claim', 'close claim', 'complete'), Transition::make('processing_claim', 'refund', 'refunded'), ]; foreach ($orders as $order) { $order_state = new \Sofa\StateMachine\Fsm($order, $transitions); $order->available_actions = $order_state->getAvailableActions(); }
-
and we end up with controller & template code decoupled from the Process logic & order:
@foreach($orders as $order) {{ $order->reference }} status: {{ $order->status }} @foreach($order->available_actions as $action) <button>{{ $action }}</button> @endforeach @endforeach
-
finally let's process the actions
// controller handling the action public function handleAction($order_id, Request $request) { $order_state = new \Sofa\StateMachine\Fsm(Order::find($order_id), $transitions); $this->validate($request, [ 'action' => Rule::in($order_state->getAvailableActions()), // ... ]); $order_state->process($request->get('action')); return Redirect::to('some/place'); }
With this setup we no longer have to change our controllers or views, whenever business requirements change. Instead we add a new transition to the state machine definition.
The above example assumes very simple transition process, ie. $order->status = $new_status
. This can be enough sometimes, but often we will need more flexibility during transitions. To address this need you can customize your Transition
definitions, so they turn from simple POPO into callable
that will be invoked, when state machine processes appropriate action:
class Refund extends \Sofa\StateMachine\Transition
{
public function __invoke(StateMachineInterface $order, $payload)
{
// $payload is any object you pass to the process method:
// $order_state->process('refund', $anything_you_need_here);
$order->refunded_at = $payload['time'];
$order->refunded_by = $payload['user_id'];
$order->setState($this->to_state);
}
}
// Then our transitions definition would like something like:
$transitions = [
// ...
Transition::make('processing_claim', 'close claim', 'complete'),
Refund::make('processing_claim', 'refund', 'refunded'),
];
Happy Coding!
All contributions are welcome. Make your PR PSR-2 compliant and tested.