The Art of Keeping Business Logic Honest

jlahijani1 pts0 comments

The Art Of Keeping Business Logic Honest — JustSteveKing Skip to main content The Art Of Keeping Business Logic Honest<br>Use state machines and workflow engines in Laravel to keep business rules explicit, auditable, and resilient across async processes.<br>20 May 2026 12 min read Laravel<br>laravelstate-machinesworkflow-enginedomain-driven-design<br>There is a moment in most long-lived applications where you open a controller and find a block of conditionals that nobody quite understands anymore. Something like if ($entity->status === 'pending' && $this->someFlag) buried inside a service class, half-guarding a transition that was probably fine when someone wrote it but now nobody wants to touch. The business logic has drifted from the code, and the code is quietly lying about what the system actually does.

I have been building a platform recently where I could see this problem coming from a long way off. The domain has entities that go through well-defined lifecycles - things move through stages, transitions have rules, and when a transition happens, a bunch of other things need to follow. The instinct is to reach for a big use case class, or a service that handles everything in sequence. But that approach tends to collapse under its own weight as requirements change.

Instead, I ended up with a two-layer pattern: a strict state machine for each entity that owns nothing but transition rules, and a separate workflow engine that responds to those transitions and orchestrates the follow-on work. This article is about how that pattern holds together and why I think it is worth the setup cost.

The Problem With Ad-Hoc Status Management

Before getting into the pattern, it is worth being honest about what you are replacing. Most applications manage entity status as a string column and a handful of conditionals scattered across services. When you need to add a new status, you add it to the column’s allowed values and start writing if checks wherever it matters. This works fine for simple lifecycles. It starts to hurt when:

You have 5+ statuses and a non-trivial transition graph

Different transitions need different side effects

Some transitions involve async operations that can fail halfway through

You need an audit trail of who triggered what and when

The state machine pattern addresses the first two. The workflow engine addresses the last two. Together they handle the full picture.

Layer One: The State Machine

A state machine in this pattern is deliberately narrow. Its only job is to know which transitions are legal and to produce a domain event when one is performed. It does not send emails. It does not touch the database. It does not call other services.

Here is what a simple order lifecycle might look like:

OrderStatus:

draft -> submitted -> approved -> fulfilled

submitted -> rejected

approved -> cancelled

submitted -> approved -> fulfilled submitted -> rejected approved -> cancelled">

The entity enforces this. If you try to transition from draft directly to fulfilled, you get a domain exception - not a silent data integrity problem discovered six months later.

src/Orders/Domain/Order.phpfinal class Order

private OrderStatus $status;

public function submit(): OrderSubmitted

if ($this->status !== OrderStatus::Draft) {

throw new InvalidTransitionException(

"Cannot submit an order in status {$this->status->value}."

);

$this->status = OrderStatus::Submitted;

return new OrderSubmitted(

orderId: $this->id,

submittedAt: new \DateTimeImmutable(),

);

public function approve(string $approvedBy): OrderApproved

if ($this->status !== OrderStatus::Submitted) {

throw new InvalidTransitionException(

"Cannot approve an order in status {$this->status->value}."

);

$this->status = OrderStatus::Approved;

return new OrderApproved(

orderId: $this->id,

approvedBy: $approvedBy,

approvedAt: new \DateTimeImmutable(),

);

status !== OrderStatus::Draft) { throw new InvalidTransitionException( "Cannot submit an order in status {$this->status->value}." ); } $this->status = OrderStatus::Submitted; return new OrderSubmitted( orderId: $this->id, submittedAt: new \DateTimeImmutable(), ); } public function approve(string $approvedBy): OrderApproved { if ($this->status !== OrderStatus::Submitted) { throw new InvalidTransitionException( "Cannot approve an order in status {$this->status->value}." ); } $this->status = OrderStatus::Approved; return new OrderApproved( orderId: $this->id, approvedBy: $approvedBy, approvedAt: new \DateTimeImmutable(), ); }}">

Notice that each method returns a domain event rather than dispatching it. The calling layer - a use case - is responsible for taking that event, persisting it to an event log, and firing it into the Laravel event system. The domain entity stays clean.

src/Orders/Application/ApproveOrder.phpfinal class ApproveOrder

public function __construct(

private readonly OrderRepositoryContract $repository,

) {}

public function execute(string...

status orderstatus submitted order domain approved

Related Articles