taecontrol / nodegraph
Build agentic apps
Fund package maintenance!
Taecontrol
Requires
- php: ^8.4
- illuminate/contracts: ^11.0||^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
NodeGraph is a tiny, testable state-graph runtime for Laravel. Define your process as an enum of states, map each state to a Node class, and let a Graph run the flow step-by-step while recording checkpoints, metadata, and dispatching events.
Core capabilities:
- Deterministic state transitions via a directed graph
- Nodes execute your domain logic and return a Decision (next state, metadata, events)
- Threads persist progress (
graph_name
,current_state
, timestamps, metadata) - Checkpoints store a timeline of transitions with merged metadata
- Multi-graph: configure multiple independent graphs, each with its own state enum
Why multi-graph?
Real systems rarely have a single lifecycle. Orders, shipments, payouts, document reviews—each has its own progression logic. Multi-graph support lets you:
- Model each lifecycle with a dedicated state enum + Graph class
- Persist them all in the same
threads
table (distinguished bygraph_name
) - Keep logic isolated while sharing infrastructure (events, metadata, checkpoints)
Requirements
- PHP >= 8.4
- Laravel (Illuminate Contracts) ^12.0 (works with ^11.0 as well per constraint, but docs target 12)
Installation
Install via Composer:
composer require taecontrol/nodegraph
Publish the migration and migrate:
php artisan vendor:publish --tag="nodegraph-migrations"
php artisan migrate
Publish the config:
php artisan vendor:publish --tag="nodegraph-config"
Locate config/nodegraph.php
. You will see a graphs
array. Each entry declares a graph name and the enum that represents its states.
Single-graph (default) usage example (Quickstart below shows code usage):
return [ 'graphs' => [ [ 'name' => 'default', 'state_enum' => \App\Domain\Order\OrderState::class, // IMPORTANT: class constant (no quotes) ], ], ];
Note: The published config may show a quoted "::class" string placeholder—replace it with the actual class constant as shown above.
Core concepts
- State enum: a PHP BackedEnum implementing
Taecontrol\NodeGraph\Contracts\HasNode
. Each enum case maps to a Node class. - Node: extends
Taecontrol\NodeGraph\Node
. Implementhandle($context)
and return aDecision
. - Decision: extends
Taecontrol\NodeGraph\Decision
. HoldsnextState()
,metadata()
, andevents()
. - Graph: extends
Taecontrol\NodeGraph\Graph
. Implementdefine()
(edges) andinitialState()
. - Context: extends
Taecontrol\NodeGraph\Context
. Provides athread()
method. - Thread model:
Taecontrol\NodeGraph\Models\Thread
storesgraph_name
,current_state
,metadata
,started_at
,finished_at
; has manycheckpoints
. - Checkpoint model:
Taecontrol\NodeGraph\Models\Checkpoint
storesstate
+ snapshot metadata per run.
How it runs
Graph::run($context)
will:
- Initialize the thread's
current_state
to the graph'sinitialState()
if null, settingstarted_at
. - Resolve the Node for the current state and execute it.
- The Node's Decision metadata is augmented with
state
andexecution_time
(seconds, float). - Thread metadata is merged under the key of the current state's enum value.
- A checkpoint is created with merged metadata; Decision events are dispatched.
- If a transition is allowed (
canTransition(current, decision->nextState())
), thread state advances. finished_at
is currently only set if a terminal state is re-run in a configuration where the terminal state has a self-transition (explicit self-edge) causingupdateThreadState
to execute while already terminal. With the common pattern (no outgoing edges),finished_at
will remainnull
unless you add such a self-edge or customize behavior.
Quickstart (single graph)
- Create a state enum mapping states to Node classes:
use Taecontrol\NodeGraph\Contracts\HasNode; enum OrderState: string implements HasNode { case Start = 'start'; case Charge = 'charge'; case Done = 'done'; public function node(): string { return match ($this) { self::Start => \App\Nodes\StartNode::class, self::Charge => \App\Nodes\ChargeNode::class, self::Done => \App\Nodes\DoneNode::class, }; } }
- Create a Decision class:
namespace App\Decisions; use Taecontrol\NodeGraph\Decision; class SimpleDecision extends Decision {}
- Create Nodes for each state:
namespace App\Nodes; use App\Decisions\SimpleDecision; use App\Enums\OrderState; use App\Events\OrderEvent; // extends Taecontrol\NodeGraph\Event use Taecontrol\NodeGraph\Node; class StartNode extends Node { public function handle($context): SimpleDecision { $d = new SimpleDecision(OrderState::Charge); $d->addMetadata('from', 'start'); $d->addEvent(new OrderEvent('start')); return $d; } } class ChargeNode extends Node { public function handle($context): SimpleDecision { // ... charge logic ... $d = new SimpleDecision(OrderState::Done); $d->addMetadata('from', 'charge'); $d->addEvent(new OrderEvent('charged')); return $d; } } class DoneNode extends Node { public function handle($context): SimpleDecision { $d = new SimpleDecision(null); // remain in terminal state $d->addMetadata('from', 'done'); $d->addEvent(new OrderEvent('done')); return $d; } }
- Define your Graph:
use Taecontrol\NodeGraph\Graph; use App\Enums\OrderState; class OrderGraph extends Graph { public function define(): void { $this->addEdge(OrderState::Start, OrderState::Charge); $this->addEdge(OrderState::Charge, OrderState::Done); // Done has no outgoing edges; it's terminal } public function initialState(): OrderState { return OrderState::Start; } }
- Provide a Context that exposes the Thread:
use Taecontrol\NodeGraph\Context; use Taecontrol\NodeGraph\Models\Thread; class OrderContext extends Context { public function __construct(protected Thread $thread) {} public function thread(): Thread { return $this->thread; } }
- Create and run a Thread (e.g. controller, job, listener):
use Taecontrol\NodeGraph\Models\Thread; $thread = Thread::create([ 'threadable_type' => \App\Models\Order::class, 'threadable_id' => (string) \Illuminate\Support\Str::ulid(), 'graph_name' => 'default', // single-graph setup uses 'default' 'metadata' => [], ]); $context = new \App\Contexts\OrderContext($thread); $graph = app(\App\Graphs\OrderGraph::class); // graph_name does NOT auto-resolve to a class $graph->run($context); // Start -> Charge $graph->run($context); // Charge -> Done $graph->run($context); // Done terminal; finished_at remains null with default pattern
Observability:
threads.current_state
moves across runsthreads.metadata
accumulates per-state metadata (includesexecution_time
)checkpoints
appended each run with merged metadata snapshot- Domain events dispatched through Laravel's event dispatcher
Advanced: Multi-graph usage
You can define multiple graphs—each with its own enum—inside the same application. All share threads
and checkpoints
tables, distinguished by graph_name
.
config/nodegraph.php
example:
return [ 'graphs' => [ [ 'name' => 'default', 'state_enum' => \App\Domain\Order\OrderState::class, ], [ 'name' => 'shipment', 'state_enum' => \App\Domain\Shipment\ShipmentState::class, ], ], ];
Second enum + graph example:
use Taecontrol\NodeGraph\Contracts\HasNode; enum ShipmentState: string implements HasNode { case Queued = 'queued'; case Picking = 'picking'; case Dispatching = 'dispatching'; case Delivered = 'delivered'; public function node(): string { return match ($this) { self::Queued => \App\Nodes\Shipment\QueuedNode::class, self::Picking => \App\Nodes\Shipment\PickingNode::class, self::Dispatching => \App\Nodes\Shipment\DispatchingNode::class, self::Delivered => \App\Nodes\Shipment\DeliveredNode::class, }; } } class ShipmentGraph extends \Taecontrol\NodeGraph\Graph { public function define(): void { $this->addEdge(ShipmentState::Queued, ShipmentState::Picking); $this->addEdge(ShipmentState::Picking, ShipmentState::Dispatching); $this->addEdge(ShipmentState::Dispatching, ShipmentState::Delivered); } public function initialState(): ShipmentState { return ShipmentState::Queued; } }
Creating threads for different graphs:
$orderThread = Thread::create([ 'threadable_type' => \App\Models\Order::class, 'threadable_id' => (string) \Illuminate\Support\Str::ulid(), 'graph_name' => 'default', ]); $shipmentThread = Thread::create([ 'threadable_type' => \App\Models\Shipment::class, 'threadable_id' => (string) \Illuminate\Support\Str::ulid(), 'graph_name' => 'shipment', ]); app(\App\Graphs\OrderGraph::class)->run(new OrderContext($orderThread)); app(\App\Graphs\ShipmentGraph::class)->run(new ShipmentContext($shipmentThread));
Important notes
graph_name
does NOT auto-resolve a Graph class—you must choose the appropriate class yourself (e.g. via a map or conditional lookup).- Each thread's state casting uses the enum from the matching config entry. If the
graph_name
is not configured, the enum cast will not apply (state behaves as a raw string). Document or validategraph_name
creation to avoid surprises. - Metadata and events are entirely isolated per thread—even across different graphs.
- To mark completion with
finished_at
, either add a self-edge on a terminal state (so an additional run triggers the mark) or extend the Graph to set it when first entering a terminal state.
Retrieving enum metadata dynamically
If you need the enum class for a given thread:
$enumClass = collect(config('nodegraph.graphs')) ->firstWhere('name', $thread->graph_name)['state_enum'] ?? null;
Check for null
if the graph might not be configured.
API cheatsheet
Graph::addEdge(From, To)
— define allowed transitionsGraph::neighbors(State): array
— list next statesGraph::canTransition(From, To): bool
— check if transition is allowedGraph::assert(From, To): void
— throws on invalid transitionsGraph::isTerminal(State): bool
— true when no outgoing edgesGraph::run(Context): void
— execute one step and persist side effects
Data model
Tables (published migration):
- threads
- id (ULID), threadable_type, threadable_id (morphs)
- graph_name (string)
- current_state (string, cast to enum when configured), metadata (json)
- started_at, finished_at, timestamps, softDeletes
- checkpoints
- id (ULID), thread_id, state (string, cast to enum when configured)
- metadata (json), timestamps, softDeletes
Both Thread::current_state
and Checkpoint::state
are cast using the selected graph's state_enum
if a matching graph_name
is found.
Behavior with unknown graph_name
If a thread references a graph_name
absent from configuration:
- No enum casting will occur (raw string states)
- You must handle validation manually
- Graph execution will still function if you manually run the appropriate Graph class with states using the same raw values
Testing
composer test
Changelog
Please see CHANGELOG for recent changes.
Contributing
Please see CONTRIBUTING for details.
Credits
License
The MIT License (MIT). Please see License File for more information.