Notes About Laracon State Machines Talk
Tags: php
Published: 2024-01-20 15:14:08

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](https://www.youtube.com/watch?v=1A1xFtlDyzU)

---

## 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`:

```php
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*

```php
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*

```php
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*

```php
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*

```php
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*

```php
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:

```php
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:

```php
abort(403, 'Invoice cannot be finalized');
```


Then `DraftInvoiceStatus` would be:

```php
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

```php
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

```php
$invoice->state(); // return a state object
```

Here is a finalize controller example:

```php
class FinalizeInvoiceController extends Controller
{
    public function __invoke(Request $request, Invoice $invoice)
    {
        $invoice->state()->finalize();
        return view('invoice.show', ['invoice' => $invoice]);
    }
}
```

 The Pay Controller:
 
 ```php
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"

```php
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](https://spatie.be/docs/laravel-model-states/v2/01-introduction), that uses this pattern behind the scenes.