Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

State Machines for Business Workflows: Stop Letting Any Code Set Any Status

About Post

Somewhere in your database there's a record that shouldn't exist. A contract that is terminated but was signed after it was terminated. An order that is refunded but never paid. An invoice that went from draft straight to overdue.

Nobody wrote code to do that on purpose. It happened because the status column was a free-for-all: any part of the app could set it to any value at any time.

The fix has a slightly intimidating name, a state machine, and a reassuringly small amount of code.

The mistake: status as a plain string

Most business workflows start life like this:

$contract->status = 'active';
$contract->save();

It's fine on day one. By month six, that line exists in a controller, an Artisan command, a queued job, an admin panel action and a webhook handler. Each was written by someone who knew their part of the workflow and not the others.

So the rules of the workflow ("you can't activate a contract that hasn't been signed") live nowhere. Or worse, they live in five places, slightly differently.

What a state machine actually is

Strip away the computer science and a state machine is three things:

  1. A fixed list of states. A contract can be draft, pending signature, active, expired or terminated. Nothing else.
  2. A list of allowed transitions. Draft can go to pending signature. Pending signature can go to active or back to draft. Active can go to expired or terminated. Expired and terminated go nowhere.
  3. One gate that every change passes through. If a transition isn't on the list, it doesn't happen.

Think of a board game. The board tells you which squares connect. You can't move from square 3 to square 40 just because you'd like to be there.

Drawn out, the contract workflow is:

draft ──► pending_signature ──► active ──► expired
  ▲               │                  │
  └───────────────┘                  └──► terminated

That diagram is worth pasting into the pull request description, too. Product people can read it, and they will spot the missing arrow you didn't think of.

Step 1: an enum for the states

PHP enums are a natural home for this, because the states and the transition rules can live together:

enum ContractStatus: string
{
    case Draft = 'draft';
    case PendingSignature = 'pending_signature';
    case Active = 'active';
    case Expired = 'expired';
    case Terminated = 'terminated';

    /** @return list<self> */
    public function allowedNext(): array
    {
        return match ($this) {
            self::Draft => [self::PendingSignature],
            self::PendingSignature => [self::Active, self::Draft],
            self::Active => [self::Expired, self::Terminated],
            self::Expired, self::Terminated => [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedNext(), true);
    }
}

The match has no default arm on purpose. If someone adds a new case and forgets to define its transitions, PHP throws an UnhandledMatchError the first time it's hit, instead of silently allowing everything.

Cast the column on the model so you never handle raw strings again:

protected function casts(): array
{
    return ['status' => ContractStatus::class];
}

Step 2: one gate for every change

Now give the model a single method that changes the status, and make it the only way in:

public function transitionTo(ContractStatus $next, ?string $reason = null): void
{
    DB::transaction(function () use ($next, $reason) {
        $fresh = static::whereKey($this->id)->lockForUpdate()->firstOrFail();

        if (! $fresh->status->canTransitionTo($next)) {
            throw new InvalidTransition($fresh->status, $next);
        }

        $this->history()->create([
            'from' => $fresh->status, 'to' => $next, 'reason' => $reason,
            'user_id' => auth()->id(),
        ]);

        $this->forceFill(['status' => $next])->save();
    });
}

Three details do the heavy lifting here:

  • The row lock. Two requests trying to activate and terminate the same contract at the same moment are processed one after the other, and the second one checks against the real current state, not a stale copy.
  • The history row. Every transition records who, when, from what, to what and why. When someone asks "why is this contract terminated?", you have an answer instead of a shrug.
  • The exception. An illegal transition is a bug or a misuse, so it should be loud.

Simplified: a real version would also fire events, keep other unsaved changes on the model out of this save, and decide what happens when the lock waits too long.

Step 3: guards for the business rules

"Is this transition on the map?" is only half the question. The other half is "are the conditions met right now?" A contract can move from pending signature to active, but only if every party has signed.

Those checks are guards. Keep them next to the transition, not scattered in controllers:

public function activate(): void
{
    if ($this->signatures()->whereNull('signed_at')->exists()) {
        throw new GuardFailed('All parties must sign before activation.');
    }

    $this->transitionTo(ContractStatus::Active);
}

Now the controller just calls $contract->activate(). The queued job that processes e-signature callbacks calls the same method. The rule exists exactly once.

The rule: nothing outside the model is allowed to write the status column directly. If you can grep your codebase for 'status' => and find assignments outside the gate, the state machine has a leak.

Side effects belong after the transition

Activating a contract usually means more than changing a column: send a confirmation, generate the first rent schedule, notify the tenant app. Put those in event listeners or queued jobs triggered after the transition succeeds, not before.

The order matters. If you send the "your contract is active" email and then the transition fails a guard, you've told someone something untrue. Transition first, then react.

Do you need a package?

There are good state machine packages for Laravel, and for complex workflows with many states, nested states or configurable transitions they're worth a look. For most business entities, though, an enum, a gate method and a history table cover it. It's a small amount of code you fully understand, and that's a feature.

Signs you've outgrown the simple version: transitions that need different permissions per role, workflows that admins configure without a deploy, or the same entity type following different workflows per customer.

Checklist

  • States are an enum, cast on the model.
  • Allowed transitions live in one place, next to the states.
  • One method changes the status, with a row lock.
  • Guards check business conditions before the transition.
  • Every transition writes a history row.
  • Side effects run after a successful transition.

What's the workflow in your app that most needs this? I'd bet it's the one where support regularly asks "how did this record end up in this state?"

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close