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.
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
InvalidStateTransitionat 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
threadstable, each with its own state enum, discriminated bygraph_nameand a polymorphicthreadable. - Terminal states are free — a state with no outgoing edges is
terminal; the graph sets
finished_at, dispatchesGraphFinished, and subsequent calls torun()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.
A package I reach for before I write a state column.
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.
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.