taecontrol / nodegraph
Build agentic apps
Fund package maintenance!
Taecontrol
Installs: 32
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 1
pkg:composer/taecontrol/nodegraph
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
threadstable (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\Threadstoresgraph_name,current_state,metadata,started_at,finished_at; has manycheckpoints. - Checkpoint model:
Taecontrol\NodeGraph\Models\Checkpointstoresstate+ snapshot metadata per run.
How it runs
Graph::run($context) will:
- Initialize the thread's
current_stateto 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
stateandexecution_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_atis currently only set if a terminal state is re-run in a configuration where the terminal state has a self-transition (explicit self-edge) causingupdateThreadStateto execute while already terminal. With the common pattern (no outgoing edges),finished_atwill remainnullunless 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_statemoves across runsthreads.metadataaccumulates per-state metadata (includesexecution_time)checkpointsappended 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_namedoes 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_nameis not configured, the enum cast will not apply (state behaves as a raw string). Document or validategraph_namecreation 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.