Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nodalflow Laravel Package

fab2s/nodalflow

NodalFlow is a PHP library for building node-based workflows and data pipelines. Compose reusable nodes into flows, pass payloads between them, and control execution order and branching with a simple, extensible API designed for framework-agnostic PHP applications.

Deep Wiki
Context7

Getting Started

Install and locate the moving parts

composer require fab2s/nodalflow

Read in this order — the package is small enough that the source is the documentation:

  1. README.md — the two-class example is essentially the whole public surface.
  2. src/NodalFlow.php — the flow object you instantiate.
  3. src/Nodes/NodeInterface.php + the node classes (CallableNode, and the agnostic/payload/traversable variants) — these define what a unit of work can return.
  4. src/Flows/FlowMap.php / FlowStatus — the runtime record of what ran, in what order, and how it ended.

Mental model: a NodalFlow is an ordered container of Nodes. A node wraps a callable (or another flow), carries a string id, and records a status plus an optional payload as the flow is traversed. Executing a flow is just iterating it — the traversal is the run. The FlowMap is the after-action report you inspect (or persist) once traversal completes.

Minimal first use case in Laravel — a step-based import where you want each step independently identifiable and inspectable:

use Fab2s\NodalFlow\NodalFlow;
use Fab2s\NodalFlow\Nodes\CallableNode;

$flow = new NodalFlow;

$flow->addNode(new CallableNode(
    fn () => app(ValidateCsv::class)->handle($path),
    'validate'
));

$flow->addNode(new CallableNode(
    fn () => app(StageRows::class)->handle($path),
    'stage'
));

$flow->addNode(new CallableNode(
    fn () => app(ImportRows::class)->handle(),
    'import'
));

foreach ($flow as $node) {
    // traversal drives execution; each node is yielded as it completes
    Log::info('flow.node', ['id' => $node->getId(), 'status' => $node->getStatus()]);
}

Starting with a linear flow of three nodes and a loop that logs each node id/status gets you 90% of the value: you immediately see which step died, which is the thing ad-hoc try/catch chains never tell you.

Implementation Patterns

Keep business logic out of the flow. The flow is orchestration, not implementation. Every node should be a one-line delegation to an existing Action/Service class or a queued-job body. This keeps the flow rebuildable and testable, since closures in nodes are the least portable artifact of the whole design.

Treat node ids as your observability contract. Ids are your join key across logs, a flow_runs / flow_nodes table, and Horizon traces. Adopt a convention up front (import.validate, import.stage, billing.charge) and never generate them dynamically — you lose the ability to correlate history if ids drift between deployments.

Branch by composition, not by conditionals inside nodes. Two workable approaches:

  • Decide before you traverse — inspect input, then addNode() only the branches that apply. Cleanest, and the FlowMap then honestly reflects which steps were in scope.
  • Return and read payloads — a node returns a value; later nodes receive it via closure capture or by reading the flow map after the fact. Keep a small $context object in scope for anything more than one value, rather than threading payloads through the node graph.

Nest flows for sub-processes. A flow can be added as a node of another flow, which gives you a two-level report (parent step billing → children charge, invoice, notify). Use this for genuinely reusable pipelines; don't nest three levels deep, because the status propagation gets hard to reason about when you're debugging.

Wrap in a queued job, rebuild in handle(). The pattern that survives Laravel production:

class RunImportFlow implements ShouldQueue
{
    public function __construct(public int $runId) {}

    public function handle(): void
    {
        $flow = ImportFlowFactory::make($this->runId); // fresh node objects, every time

        try {
            foreach ($flow as $node) {
                FlowNodeRecord::where('run_id', $this->runId)
                    ->where('node_id', $node->getId())
                    ->update(['status' => $node->getStatus()]);
            }
        } catch (Throwable $e) {
            FlowRun::find($this->runId)->markFailed($e->getMessage());
            throw $e;
        }
    }
}

The DB row per node id is what turns an opaque synchronous library into something you can resume, alert on, and show in an admin panel.

Resume rather than restart. For long flows, persist the last successful node id. On retry, build the flow and skip (or replace with no-op nodes) everything up to and including that id. The package will faithfully re-run anything you give it; idempotency and skipping are your responsibility, not the flow's.

Container-aware node construction. The package has no container awareness. Resolve dependencies when building nodes (app(StageRows::class)) rather than inside the closure, so failures surface at flow-build time rather than halfway through execution.

Test at the flow level. Assert on the FlowMap rather than on individual services:

$flow = ImportFlowFactory::make($run->id);
iterator_to_array($flow); // force execution

$this->assertSame(NodeStatus::SUCCESS, $flow->getNode('stage')->getStatus());

Fake the heavy steps with a CallableNode returning a fixed value; keep one integration test against the real flow so ordering regressions get caught.

Decorator for observability. If you want timing, events, or Log::withContext, wrap the callable rather than editing business classes — a small TimedNode/LoggingNode extending the callable node gives you uniform instrumentation across every flow in the app.

Gotchas and Tips

It is synchronous and in-process. There is no queue, no retry, no timeout, no backoff, no concurrency. Every node runs on the calling thread. A 40-minute import will hit max_execution_time and your web request will die. Anything non-trivial belongs inside a queued job, and long steps belong inside their own nested jobs.

Traversal equals execution. Iterating the flow runs it. Consequences: don't iterate twice (you'll re-execute or hit exhausted/invalid state), don't iterator_to_array() "just to inspect," and don't return the flow object from a controller expecting it to be lazy. If you need inspection without execution, build the flow and inspect the nodes you added, not the traversed result.

Node instances carry state. Status and payload live on the node object. Reusing a flow instance across two runs in the same process (or an Octane worker, or a singleton binding) gives you stale statuses and misleading flow maps. Always construct a fresh flow per run; never register one in the container as a singleton.

Closures are not serializable. You cannot put a flow on a queue, cache it, or store it in a job payload. Only ever queue a recipe (ids, config) and rebuild the flow in handle(). This also rules out serializing the FlowMap wholesale unless you extract plain arrays from it.

An uncaught exception aborts traversal. There is no per-node try/catch by default and no "continue on error" mode. If you want best-effort semantics, catch inside the node callable and return a sentinel — but then the node reports success, so be explicit about which status vocabulary you're using and don't rely on it for alerting.

Ids must be unique. Duplicate ids mean you can't look a node up unambiguously and your persistence key collides. Enforce uniqueness in your flow factory (a simple assert over the id list) — this is cheap and catches copy-paste errors immediately.

Payloads are retained. Whatever nodes return stays referenced by the flow for its lifetime. Returning full Eloquent collections from every node will bloat memory in long flows; return ids or counts and re-query inside the next node.

No built-in logging, metrics, or events. Nothing will tell you a node failed unless you look. Build the log/persist loop from the pattern above on day one — retrofitting observability into an existing set of flows is far more painful.

Distinguish node status from exception type. Learn the difference between a node that finished with a failure-indicating status and one that threw. Your error handling should branch on this: thrown exceptions mean the flow stopped mid-way and the DB state is inconsistent; status failures mean the flow completed and you can decide what to do.

Small ecosystem, single maintainer. 16 stars, no Laravel adapter, no Stack Overflow corpus. Pin the version explicitly in composer.json and read tests/ before depending on any non-obvious behavior — the tests are the real specification for edge cases like empty flows, nested flow failure propagation, and restart semantics. If you rely on an undocumented detail, leave a comment linking to the line in vendor/; a minor bump can move it.

Check the PHP requirement against your matrix. With a very recent release date, expect a modern PHP floor. If you're pinned to an older runtime (or a Laravel version that supports one), verify composer.json's require block before planning around it.

Extension points are inheritance-based. There's no plugin registry — you extend the node base classes to add behavior. That's fine and simple, but it means custom node types live in your app and must be re-checked whenever the package's node internals change. Keep them thin.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator
equipc/laravel-email-database-log
adedsec/nova-translated-labels
wazza/laravel-db-encryption
hokny/laravel-generator
vonsogt/backup
jengo/queues
jeremykenedy/laravel-observability
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums