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, wire each state to a Node class, and let a Graph run the flow step-by-step while recording checkpoints, metadata, and dispatching events.
- Deterministic state transitions via a directed graph
- Nodes execute your domain logic and return a Decision (next state, metadata, events)
- Threads persist progress (current_state, started_at/finished_at, metadata)
- Checkpoints store a timeline of transitions with merged metadata
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"
Then set the enum class used to cast the current_state
and checkpoint state
fields. In config/nodegraph.php
:
return [ // IMPORTANT: use the class constant (no quotes) 'state_enum' => \App\Domain\Agent\YourStateEnum::class, ];
Core concepts
- State enum: a PHP BackedEnum that implements
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()
to add edges andinitialState()
. - Context: extends
Taecontrol\NodeGraph\Context
. Provides athread()
method. - Thread model:
Taecontrol\NodeGraph\Models\Thread
storescurrent_state
,metadata
,started_at
,finished_at
and has manycheckpoints
. - Checkpoint model:
Taecontrol\NodeGraph\Models\Checkpoint
storesstate
andmetadata
snapshots.
How it runs
When you call Graph::run($context)
:
- If the thread has no
current_state
, it's set toinitialState()
andstarted_at
is recorded. - The Node for the current state is resolved from the container and executed.
- The Node returns a Decision. Execution time and current state are automatically added to Decision metadata.
- Thread metadata is merged under the current state's key, a Checkpoint is created with merged metadata, and Decision events are dispatched.
- If allowed by the graph edges, the thread advances to the Decision's
nextState()
; otherwise it remains in place. On a subsequent run when at a terminal state (no outgoing edges),finished_at
is set.
Quickstart
- Create a state enum that maps 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); // stay 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, so 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. from a controller, job, or listener):
use Taecontrol\NodeGraph\Models\Thread; $thread = Thread::create([ 'threadable_type' => \App\Models\Order::class, // anything morphable 'threadable_id' => (string) \Illuminate\Support\Str::ulid(), 'metadata' => [], ]); $context = new \App\Contexts\OrderContext($thread); $graph = app(\App\Graphs\OrderGraph::class); $graph->run($context); // Start -> Charge $graph->run($context); // Charge -> Done $graph->run($context); // Done is terminal; finished_at will be set on this run
What you get:
threads.current_state
advances across runs;started_at/finished_at
are set.threads.metadata
accumulates per-state metadata, includingexecution_time
.checkpoints
are appended each run with merged metadata.- Your
OrderEvent
instances are dispatched via Laravel'sevent()
helper.
API cheatsheet
Graph::addEdge(From, To)
— define allowed transitions.Graph::neighbors(State): array
— list next states.Graph::canTransition(From, To): bool
— validate a transition.Graph::assert(From, To): void
— throws on invalid transitions.Graph::isTerminal(State): bool
— true when a state has no outgoing edges.Graph::run(Context): void
— runs one step and persists side effects.
Data model
This package ships two tables (via the publishable migration):
- threads
- id (ULID), threadable_type, threadable_id (morphs)
- current_state (string, cast to your enum), metadata (json)
- started_at, finished_at, timestamps, softDeletes
- checkpoints
- id (ULID), thread_id, state (string, cast to your enum)
- metadata (json), timestamps, softDeletes
Both Thread::current_state
and Checkpoint::state
are cast using your state_enum
config.
Testing
composer test
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Credits
License
The MIT License (MIT). Please see License File for more information.