All posts

/2 min/Moffat Munene

Database-driven state machines for Laravel revenue systems

Why we extracted moffhub/flow from production county-revenue work and the pattern of approval gates, role guards and auditable transitions that comes out of it. Post 1 of the moffhub/flow series.

  • #laravel
  • #php
  • #state-machine
  • #workflows

Most Laravel state-machine packages assume your transitions live in code. That's fine for ordering pizzas. It is not fine for a revenue system where the transition rules change every fiscal year by gazette notice, and the auditor wants a paper trail of every gate that fired.

So we wrote moffhub/flow. It's the package powering the approval pipelines in the eRev county revenue system, extracted, and made boringly reusable.

The shape of the problem

A receipt in a county revenue system passes through gates like:

  1. Drafted by a clerk
  2. Reviewed by a supervisor (must be a different user)
  3. Approved by a finance officer (only above a threshold)
  4. Posted to GL
  5. Reversed only by an admin, only within 24 hours, only if not yet remitted

Each transition has guards (role checks, business rules, time windows), side effects (notifications, GL postings), and must be replayable for an audit later. Hand-rolling this with match statements gets ugly fast.

What flow looks like

use Moffhub\Flow\Workflow;

$flow = Workflow::for(Receipt::class)
    ->states(['drafted', 'reviewed', 'approved', 'posted', 'reversed'])
    ->initial('drafted')
    ->transition('drafted', 'reviewed')
        ->guard(DifferentUserGuard::class)
        ->guard(RoleGuard::supervisor())
    ->transition('reviewed', 'approved')
        ->guard(RoleGuard::financeOfficer())
        ->guard(ThresholdGuard::class)
    ->transition('approved', 'posted')
        ->action(PostToGl::class)
    ->transition('posted', 'reversed')
        ->guard(WithinHoursGuard::hours(24))
        ->guard(NotYetRemittedGuard::class)
        ->action(ReverseGl::class);

The transitions live in the database (one row per edge), so a finance lead can adjust thresholds without a deploy. The guards and actions are still code that's where the safety lives.

The audit log isn't optional

Every attempted transition writes a row: the actor, the source state, the target state, which guard rejected it (if any), and a serialized snapshot of the model. When an auditor asks "who approved this on 14 February" the answer is one query, not a Slack archaeology dig.

What we left out on purpose

  • Parallel branches. A receipt is a token, not a process diagram. If you need parallelism, you need BPMN, and you should not be reading this post.
  • Time-based auto-transitions. They cause incidents at 03:00. We require a human or a job to fire transitions explicitly.
  • State-machine-as-code DSLs that look impressive in a slide. Boring config beats clever DSL when the auditor is reading it.

Get it

composer require moffhub/flow

It's at v0.0.1 production-tested in eRev, but APIs may still wobble. If you're building approval gates in Laravel and the existing packages feel too generic, give it a spin and tell us where it breaks.

Package for this post

Database-driven state machines for Laravel

Approval gates, role-based guards, auditable transitions, configurable actions. Extracted from production county-revenue work.

composer require moffhub/flow