Upgrade to Pro — share decks privately, control downloads, hide ads and more …

State Machines in Laravel

State Machines in Laravel

Determining the current state of an entity can be difficult. For many, determining state is always implicit — typically taking the form of booleans and timestamps littered throughout database columns and the codebase. The ever growing list of rules used to determine state are rarely documented and even less often tested. What can we do?

Simple state machines, allow us to make our state explicit and provide clear instructions on when and how that state should change. By making our state explicit, we can eliminate edge cases, write less code, and confidently test our implementations. The great news is that you don’t need to make large changes to benefit from this simple pattern!

During this talk, participants will learn:

- The event-state-action paradigm
- How to create a state chart / diagram
- How to implement a simple state machine
- How to refactor to a simple state machine from existing code

Jake Bennett

July 20, 2023
Tweet

Other Decks in Programming

Transcript

  1. The Problem Actions Create - User creates an Invoice Edit

    - Update our Invoice Details Finalize - User fi nalizes Invoice to make it ready for payment Pay - Customer pays the Invoice
  2. class InvoiceController extends Controller { public function store(CreateInvoiceRequest $request) {

    Invoice::create($request->validate()); return redirect()->route('invoice.index'); } public function update(UpdateInvoiceRequest $request, Invoice $invoice) { $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } }
  3. class InvoiceController extends Controller { public function store(CreateInvoiceRequest $request) {

    Invoice::create($request->validate()); return redirect()->route('invoice.index'); } public function update(UpdateInvoiceRequest $request, Invoice $invoice) { $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } }
  4. class InvoiceController extends Controller { public function store(CreateInvoiceRequest $request) {

    Invoice::create($request->validate()); return redirect()->route('invoice.index'); } public function update(UpdateInvoiceRequest $request, Invoice $invoice) { $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } }
  5. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  6. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  7. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  8. class InvoiceController extends Controller { // ... public function update(UpdateInvoiceRequest

    $request, Invoice $invoice) { $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } // ... }
  9. class InvoiceController extends Controller { // ... public function update(UpdateInvoiceRequest

    $request, Invoice $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be updated’); } $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } // ... }
  10. class InvoiceController extends Controller { // ... public function update(UpdateInvoiceRequest

    $request, Invoice $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be updated’); } $invoice->update($request->validate()); return redirect()->route('invoice.show', $invoice); } // ... }
  11. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  12. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  13. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  14. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  15. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (blank($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  16. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (blank($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  17. The Problem • Di ffi cult to determine what rules

    apply when • Duplication of logic every place we want to update Invoice • Code only ever grows in complexity… for example
  18. The Problem What Needs to Change 1. Add new Flags

    for each Action 2. Add conditionals wherever we update Invoice
  19. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (blank($invoice->finalized_at) || filled($invoice->paid_at)) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  20. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if ( blank($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->uncollectable_at) || filled($invoice->void_at) ) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice));
  21. The Pattern What is a State Machine? a State Machine

    is a way to help us model how a process goes from one state to another state when an event occurs.
  22. The Pattern What is a State Machine? A state machine

    is a way to help us to model our application logic.
  23. The Pattern What is a State? A State, models how

    a system responds to events for a particular point in time.
  24. The Pattern What is a State? A State, models how

    a system responds to events for a particular point in time.
  25. The Pattern Apply to our Invoices States Events • Finalize

    • Pay • Cancel • Void • Draft • Open • Paid • Void • Uncollectable
  26. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} }
  27. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} }
  28. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() { $this->invoice->update(['status' => ‘open']); } function pay() {} function void() {} function cancel() {} }
  29. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() { $this->invoice->update(['status' => ‘open']); Mail::send(new InvoiceDue($invoice)); } function pay() {} function void() {} function cancel() {} }
  30. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() { $this->invoice->update(['status' => ‘open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() {} function void() {} function cancel() {} }
  31. Event = Method The Implementation class DraftInvoiceState implements InvoiceStateContract {

    function finalize() { $this->invoice->update(['status' => ‘open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  32. Event = Method The Implementation class OpenInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} }
  33. Event = Method The Implementation class OpenInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} }
  34. Event = Method The Implementation class OpenInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() {} function void() {} function cancel() {} }
  35. Event = Method The Implementation class OpenInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() {} function void() {} function cancel() {} }
  36. class OpenInvoiceState implements InvoiceStateContract { function finalize() { throw new

    Exception(); } function pay() { $this->invoice->update(['status' => 'paid']); } function void() { $this->invoice->update(['status' => 'void']); } Event = Method The Implementation
  37. { function finalize() { throw new Exception(); } function pay()

    { $this->invoice->update(['status' => ‘paid']); Mail::send(new InvoicePaid($this->invoice)); } function void() { $this->invoice->update(['status' => 'void']); } function cancel() { $this->invoice->update(['status' => 'uncollectable']); } Event = Method The Implementation
  38. Event = Method The Implementation class UncollectableInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} }
  39. Event = Method The Implementation class UncollectableInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() {} function void() {} function cancel() { throw new Exception(); } }
  40. Event = Method The Implementation class UncollectableInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() {} function void() {} function cancel() { throw new Exception(); } }
  41. Event = Method The Implementation class UncollectableInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() { $this->invoice->update(['status' => 'paid']); } function void() { $this->invoice->update(['status' => 'void']); }
  42. Event = Method The Implementation class UncollectableInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() { $this->invoice->update(['status' => ‘paid']); Mail::send(new CancelledInvoicePaid($this->invoice)); } function void() { $this->invoice->update(['status' => 'void']); }
  43. Event = Method The Implementation class PaidInvoiceState implements InvoiceStateContract {

    function finalize() {} function pay() {} function void() {} function cancel() {} } class VoidInvoiceState implements InvoiceStateContract { function finalize() {} function pay() {} function void() {} function cancel() {} }
  44. Event = Method The Implementation class PaidInvoiceState implements InvoiceStateContract {

    function finalize() { throw new Exception(); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } } class VoidInvoiceState implements InvoiceStateContract { function finalize() { throw new Exception(); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  45. Cleaning Up The Implementation class BaseInvoiceState implements InvoiceStateContract { function

    __construct(public Invoice $invoice) {} function finalize() {} function pay() {} function void() {} function cancel() {} }
  46. Cleaning Up The Implementation class BaseInvoiceState implements InvoiceStateContract { function

    __construct(public Invoice $invoice) {} function finalize() { throw new Exception(); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  47. Cleaning Up The Implementation class DraftInvoiceState implements InvoiceStateContract { function

    finalize() { $this->invoice->update(['status' => 'open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  48. Cleaning Up The Implementation class DraftInvoiceState implements InvoiceStateContract { function

    finalize() { $this->invoice->update(['status' => 'open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  49. Cleaning Up The Implementation class DraftInvoiceState extends BaseInvoiceState { function

    finalize() { $this->invoice->update(['status' => 'open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  50. Cleaning Up The Implementation class DraftInvoiceState extends BaseInvoiceState { function

    finalize() { $this->invoice->update(['status' => 'open']); Mail::send(new InvoiceDue($this->invoice)); } function pay() { throw new Exception(); } function void() { throw new Exception(); } function cancel() { throw new Exception(); } }
  51. Cleaning Up The Implementation class DraftInvoiceState extends BaseInvoiceState { function

    finalize() { $this->invoice->update(['status' => 'open']); Mail::send(new InvoiceDue($this->invoice)); } }
  52. Model State Machine The Implementation class Invoice extends Model {

    protected $attributes = [ 'status' => 'draft', ]; }
  53. Model State Machine The Implementation class Invoice extends Model {

    protected $attributes = [ 'status' => 'draft', ]; public function state(): InvoiceStateContract { } }
  54. Model State Machine The Implementation 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'), };
  55. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->void_at) || filled($invoice->cancelled_at) ) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  56. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->void_at) || filled($invoice->cancelled_at) ) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  57. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->void_at) || filled($invoice->cancelled_at) ) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  58. class FinalizeInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if (filled($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->void_at) || filled($invoice->cancelled_at) ) { abort(403, 'Invoice cannot be finalized'); } $invoice->update(['finalized_at' => now()]); Mail::send(new InvoiceDue($invoice)); return view('invoice.show', ['invoice' => $invoice]); } }
  59. Usage The Implementation class FinalizeInvoiceController extends Controller { public function

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

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

    $invoice) { if ( blank($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->uncollectable_at) || filled($invoice->void_at) ) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  62. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

    $invoice) { if ( blank($invoice->finalized_at) || filled($invoice->paid_at) || filled($invoice->uncollectable_at) || filled($invoice->void_at) ) { abort(403, 'Invoice cannot be paid'); } $invoice->update(['paid_at' => now()]); Mail::send(new InvoicePaid($invoice)); return view('invoice.thanks', ['invoice' => $invoice]); } }
  63. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

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

    $invoice) { $invoice->state()->pay(); return view('invoice.thanks', ['invoice' => $invoice]); } }
  65. class UncollectableInvoiceState implements InvoiceStateContract { function pay() { $this->invoice->update(['status' =>

    ‘paid']); Mail::send(new CancelledInvoicePaid($this->invoice)); } } class OpenInvoiceState implements InvoiceStateContract { function pay() { $this->invoice->update(['status' => ‘paid']); Mail::send(new InvoicePaid($this->invoice)); } }
  66. class UncollectableInvoiceState implements InvoiceStateContract { function pay() { $this->invoice->update(['status' =>

    ‘paid']); Mail::send(new CancelledInvoicePaid($this->invoice)); } } class OpenInvoiceState implements InvoiceStateContract { function pay() { $this->invoice->update(['status' => ‘paid']); Mail::send(new InvoicePaid($this->invoice)); } }
  67. class PayInvoiceController extends Controller { public function __invoke(Request $request, Invoice

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

    $invoice) { $invoice->state()->pay(); return view('invoice.thanks', ['invoice' => $invoice]); } }
  69. • Di ffi cult to determine what rules apply when

    • Duplication of logic every place we want to update Invoice • Code only ever grows in complexity The Implementation Benefits
  70. • Di ffi cult to determine what rules apply when

    • Duplication of logic every place we want to update Invoice • Code only ever grows in complexity The Implementation Benefits
  71. • Di ffi cult to determine what rules apply when

    • Duplication of logic every place we want to update Invoice • Code only ever grows in complexity The Implementation Benefits
  72. • Di ffi cult to determine what rules apply when

    • Duplication of logic every place we want to update Invoice • Code only ever grows in complexity The Implementation Benefits