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:
- Drafted by a clerk
- Reviewed by a supervisor (must be a different user)
- Approved by a finance officer (only above a threshold)
- Posted to GL
- 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.