DEV Community

Ariel Mejia
Ariel Mejia

Posted on

Notes About Laracon State Machines Talk

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
Enter fullscreen mode Exit fullscreen mode

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',
    ];
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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(); }
}
Enter fullscreen mode Exit fullscreen mode

Instead of an Exception, we can abort too:

abort(403, 'Invoice cannot be finalized');
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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'),
    };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Pay Controller:

class PayInvoiceController extends Controller
{
    public function __invoke(Request $request, Invoice $invoice)
    {
        $invoice->state()->pay();
        return view('invoice.show', ['invoice' => $invoice]);
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)