Mastering the State Pattern in Laravel: Beyond Conditional Chaos
Overview
Conditional logic is a silent killer in scaling applications. What starts as a simple if statement to check an invoice status quickly ballooned into a nightmare of nested checks, boolean flags, and hard-to-track dependencies. When multiple controllers or command-line tools need to interact with the same entity, keeping those rules consistent becomes nearly impossible. This is where the
By modeling our application logic as a series of defined states and transitions, we move away from "Event -> Action" thinking and toward "Event -> Delegate to State." This shift ensures that an invoice can only be paid if it is in a valid state to be paid, centralizing that logic in a way that is self-documenting and incredibly easy to test. It transforms a messy, procedural mess into an elegant, object-oriented architecture.
Prerequisites
To follow this tutorial, you should be comfortable with
Key Libraries & Tools
- Laravel Eloquent: Used to persist the state (status) of our entities in the database.
- Figma: Mentioned as a tool for visualizing state diagrams before writing a single line of code.
- Refactoring Guru: A high-quality resource for understanding the theoretical State Patternused in this implementation.
- Spatie Laravel State: A popular package alternative if you prefer a pre-built solution for state transitions.
Code Walkthrough
Implementing a state machine starts with your model. First, we define a status column on our Invoice model and set a default state.
class Invoice extends Model
{
protected $attributes = [
'status' => 'draft',
];
public function state(): InvoiceStateContract
{
return match ($this->status) {
'draft' => new DraftInvoiceState($this),
'open' => new OpenInvoiceState($this),
'paid' => new PaidInvoiceState($this),
default => throw new InvalidArgumentException('Invalid State'),
};
}
}
Next, we define an interface to ensure every state class handles the same events. If a state shouldn't allow an action, it will throw an exception by default via a base class.
abstract class BaseInvoiceState implements InvoiceStateContract
{
public function __construct(protected Invoice $invoice) {}
public function finalize() { throw new DomainException('Action not allowed'); }
public function pay() { throw new DomainException('Action not allowed'); }
}
Now, look how clean a specific state becomes. The DraftInvoiceState only needs to override the finalize method because that is the only transition allowed from a draft.
class DraftInvoiceState extends BaseInvoiceState
{
public function finalize(): void
{
$this->invoice->update(['status' => 'open']);
Mail::to($this->invoice->user)->send(new InvoiceFinalized($this->invoice));
}
}
Finally, in your controller, you no longer need complex if statements. You simply fetch the state and call the event method.
public function __invoke(Invoice $invoice)
{
$invoice->state()->finalize();
return back();
}
Syntax Notes
The implementation relies heavily on the match expression introduced in switch statements when mapping database strings to class instances. We also use Constructor Property Promotion in the BaseInvoiceState to keep our boilerplate minimal.
Practical Examples
This pattern is a perfect fit for any multi-step workflow. Beyond invoicing, consider a Content Management System (CMS) where an article moves from Draft to Under Review to Published. Or an Order Processing System where an order transitions from Pending to Shipped to Delivered. In each case, the behavior of a "Cancel" event changes depending on whether the item has already shipped.
Tips & Gotchas
- Avoid Magic Strings: While we used strings for simplicity, always use Enumfor your status values to avoid typos that break your
matchexpression. - Final States: Remember that some states (like
PaidorVoid) are terminal. They should likely throw exceptions for every single event to prevent further transitions. - Side Effects: Keep your state classes focused. If a transition triggers a massive chain of events, consider firing a Laravel Eventsfrom within the state class rather than packing all the logic there.
