Project № VI · Case study

NodeGraph — a small state machine for agentic Laravel

An open-source PHP package for running processes as directed graphs of states. Nodes execute domain logic, threads persist progress, and checkpoints keep an auditable timeline — the runtime I wanted every time I tried to build an agent inside a Laravel app.

I.Problem

Agent-shaped workflows do not fit inside a single controller.

Every agentic feature I built inside a Laravel app arrived wanting the same shape — a process that moves through states, pauses, resumes, branches on what the model said last, and needs to be auditable after the fact. The options on hand were all wrong in the same way. A fat controller hid the transitions. A queue of jobs scattered them. A bespoke state column on an Eloquent model started clean and ended as a spreadsheet of if branches.

What was missing was a runtime that treated the graph as the thing, and let the domain logic stay small. A place to write Start → Charge → Done once, and have every transition recorded, every invalid move rejected, every event dispatched only after the database had actually committed.

The agent is the interesting part. The bookkeeping around it is not. I wanted the bookkeeping to be a library, so the interesting part could be small.

II.Approach

An enum of states, a node per state, a decision per step.

NodeGraph settles on a deliberately small surface. A state enum enumerates the process; each case maps to a Node class. A Node's handle() returns a Decision — next state, metadata, events — and the Graph validates the transition against edges declared once in define(). Progress lives on a Thread, and every step writes a Checkpoint with merged metadata and the execution time.

  • Deterministic transitions — undeclared moves throw InvalidStateTransition at runtime, so the graph cannot quietly drift.
  • Atomic writes, then events — the thread update, checkpoint, and state advance happen inside a transaction with lockForUpdate; events dispatch only after the commit succeeds. No half-written state, no events fired for work that rolled back.
  • Multi-graph by design — multiple independent lifecycles share one threads table, each with its own state enum, discriminated by graph_name and a polymorphic threadable.
  • Terminal states are free — a state with no outgoing edges is terminal; the graph sets finished_at, dispatches GraphFinished, and subsequent calls to run() no-op.

The shape a user writes looks like this:

enum OrderState: string implements HasNode {
    case Start = 'start';
    case Charge = 'charge';
    case Done = 'done';
 
    public function node(): string {
        return match ($this) {
            self::Start  => StartNode::class,
            self::Charge => ChargeNode::class,
            self::Done   => DoneNode::class,
        };
    }
}
 
class OrderGraph extends Graph {
    public function define(): void {
        $this->addEdge(OrderState::Start,  OrderState::Charge);
        $this->addEdge(OrderState::Charge, OrderState::Done);
    }
 
    public function initialState(): OrderState {
        return OrderState::Start;
    }
}

The hardest part of the design was what to leave out. No DSL, no YAML, no visual editor, no parallel branches. A graph is a PHP class, a node is a PHP class, and the runtime is a few hundred lines. Enough to be useful; small enough to read in one sitting.

III.Outcome

A package I reach for before I write a state column.

0.2.1
Latest release, MIT licensed
1
Runtime replacing a pile of ad-hoc state columns
n
Independent graphs per app, one threads table

NodeGraph is now the first thing I add to a Laravel project when the feature in front of me is shaped like a process — an agent loop, an onboarding flow, a multi-step billing state, anything where what happened and when matters as much as the final value. The checkpoint trail has paid for itself several times over in debugging alone.

The surface is small on purpose. I keep being tempted to grow it — retries built in, scheduled transitions, a Filament panel — and I keep resisting, because every one of those is a decision the host application can make better than the library can.

IV.Retrospective

The runtime is the boring part. That is the point.

The first draft tried to be clever about concurrency — a background worker that advanced threads automatically, a scheduler, a retry policy. It was a bad instinct. The host application already has Horizon, already has a scheduler, already has opinions about retries. NodeGraph's job is to make one step correct and auditable; the application's job is to decide when to take the next one.

The second lesson was about events. Dispatching them inside the transaction felt natural and was wrong — listeners would fire for work that later rolled back. Moving the dispatch to afterCommit was a two-line change that removed a whole class of bugs I had not yet hit but would have.

The third lesson is the one I keep relearning across projects. A tiny, legible runtime beats a powerful framework with a manual. Read-in-one- sitting is a feature.

Still cutting flags. Still resisting the DSL.