Notes About Laracon State Machines Talk
Ariel Mejia
Posted on January 20, 2024
This is a summary about the excellent talk about the "State Machines" pattern by Jake Bennet, you can find the link to the video here
State Machines
There are:
- States (eg: Locked & Unlocked)
- Events (triggers eg: Pay, Push)
- Transitions (From one state to another)
States
Models how a system responds to events for a particular point in time.
Explanation
An event like throwing a toy at a dog would change if a dog state is awake or if is sleeping.
Case of use
An Invoice could have different states:
- Draft
- Open
- Paid
- Uncollectable
- Void
The Events that trigger to change from one state to another would be:
- Finalize (no more edits, ready to pay)
- Pay
- Void
- Cancel
Invoice Workflow
Events are in curly braces:
Draft->{Finalize}->Open->{Pay}->Paid
Open->{Void}->Void
Open->{Cancel}->Uncollectable
Void->{Cancel}->Uncollectable
Uncollectable->{Pay}->Paid
You are going to be able to see that in this case for this particular implementation, there are two final states
Void
and Paid
states that do not change to any other state.
Code
We are going to set by default the property status as draft
:
class Invoice extends Model
{
protected $attributes = [
'status' => 'draft',
];
}
The state should be represented as a class, so for an invoice, it should be represented in directories like this:
app/
StateMachines/
Invoice/
DraftInvoiceState.php
OpenInvoiceState.php
PaidInvoiceState.php
UncollectableInvoiceState.php
VoidInvoiceState.php
It requires also methods that represent all the events that could be executed and implement them in all the states, we are going to use a contract:
interface InvoiceStateContract
{
public function finalize();
public function paid();
public function void();
public function cancel();
}
States Implementation
Here we can update a model state but also do other things like add some validations or send a notification, etc.
Every State could be represented by a class:
Draft
class DraftInvoiceState implements InvoiceStateContract
{
public function finalize()
{
$this->invoice->update([
'status' => 'open',
]);
Mail::send(new InvoiceDue($this->invoice));
}
public function pay()
{
throw new Exception();
}
public function void()
{
throw new Exception();
}
public function cancel()
{
throw new Exception();
}
}
Open
class OpenInvoiceState implements InvoiceStateContract
{
public function finalize()
{
throw new Exception();
}
public function pay()
{
$this->invoice->update([
'status' => 'paid',
]);
Mail::send(new InvoicePaid($this->invoice));
}
public function void()
{
$invoice = $this->invoice->update([
'status' => 'void',
]);
}
public function cancel()
{
$invoice = $this->invoice->update([
'status' => 'uncollectable',
]);
}
}
Uncollectable
class UncollectableInvoiceState implements InvoiceStateContract
{
public function finalize()
{
throw new Exception();
}
public function pay()
{
$this->invoice->update([
'status' => 'paid',
]);
Mail::send(new CancelledInvoicePaid($this->invoice));
}
public function void()
{
$invoice = $this->invoice->update([
'status' => 'void',
]);
}
public function cancel()
{
throw new Exception();
}
}
Void
class VoidInvoiceState implements InvoiceStateContract
{
public function finalize()
{
throw new Exception();
}
public function pay()
{
throw new Exception();
}
public function void()
{
throw new Exception();
}
public function cancel()
{
throw new Exception();
}
}
Paid
class PaidInvoiceState implements InvoiceStateContract
{
public function finalize()
{
throw new Exception();
}
public function pay()
{
throw new Exception();
}
public function void()
{
throw new Exception();
}
public function cancel()
{
throw new Exception();
}
}
Cleanup
To reduce boilerplate code we are going to implement a base class:
class BaseInvoiceStatus implements InvoiceStateContract
{
public function __construct(public Invoice $invoice) {}
public function finalize() { throw new Exception(); }
public function pay() { throw new Exception(); }
public function void() { throw new Exception(); }
public function cancel() { throw new Exception(); }
}
Instead of an Exception, we can abort too:
abort(403, 'Invoice cannot be finalized');
Then DraftInvoiceStatus
would be:
class DraftInvoiceStatus extends BaseInvoiceStatus
{
public function finalize()
{
$this->invoice->update([
'status' => 'open',
]);
Mail::send(new InvoiceDue($this->invoice));
}
// Only override the "Events" it should care about
}
The same for the remaining state objects
Working with state classes
In our Invoice
Model we can add a method that return the State object based on the current Invoice
status property
public function state(): InvoiceStateContract
{
return match($this->status) {
"draft" => new DraftInvoiceState($this),
"open" => new OpenInvoiceState($this),
"paid" => new PaidInvoiceState($this),
"void" => new VoidInvoiceState($this),
"uncollectable" => new UncollectableInvoiceState($this),
default => throw new InvalidArgumentException('Invalid Status'),
};
}
Here we can go further and replace strings that represent status for backed enums if we want.
Implementation - States Usage
$invoice->state(); // return a state object
Here is a finalize controller example:
class FinalizeInvoiceController extends Controller
{
public function __invoke(Request $request, Invoice $invoice)
{
$invoice->state()->finalize();
return view('invoice.show', ['invoice' => $invoice]);
}
}
The Pay Controller:
class PayInvoiceController extends Controller
{
public function __invoke(Request $request, Invoice $invoice)
{
$invoice->state()->pay();
return view('invoice.show', ['invoice' => $invoice]);
}
}
You would not need to take care of state validations, it is going to be delegated to the State
classes.
Every State could handle different behavior for the same method without issues and you should not take care about it as the state()
method would return whatever state is the current state for the invoice and validate it by itself, this is the principle "Tell, Don't Ask"
class OpenInvoiceState implements InvoiceStateContract
{
public function pay()
{
$this->invoice->update([
'status' => 'paid',
]);
Mail::send(new InvoicePaid($this->invoice));
}
}
class UncollectableInvoiceState implements InvoiceStateContract
{
public function pay()
{
$this->invoice->update([
'status' => 'paid',
]);
Mail::send(new CancelledInvoicePaid($this->invoice));
}
}
In this case, OpenInvoiceState
& UncollectableInvoiceState
handle the pay
event/method, but every state handles this in the way required by itself.
Conclutions
This pattern allows to:
- Remove difficult to determine what rules to apply.
- Remove duplication of logic in every place we need to update an invoice
- Reduce code complexity when it grows
You can handle this by yourself or use a package like Spatie model states, that uses this pattern behind the scenes.
Posted on January 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.