imtiaz-hasan/flowforge

Flowforge - an n8n-style workflow automation engine for Laravel. Define triggers, actions, and branching flows in pure PHP.

Maintainers

Package info

github.com/Imtiaz-Hasan/flowforge

pkg:composer/imtiaz-hasan/flowforge

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-07-08 15:11 UTC

This package is auto-updated.

Last update: 2026-07-08 15:25:52 UTC


README

Latest Version Total Downloads License

Flowforge

An n8n-style workflow automation engine for Laravel. Define triggers, actions, and branching flows in pure PHP.

Flowforge lets you wire up workflows out of small, connected nodes. A trigger starts a run, data flows from one node to the next, conditions branch the path, and every step is logged. It runs on your queue, retries failed steps, and is built to be extended with your own node types.

 trigger ──> [ transform ] ──> < condition > ──true──> [ http_request ] ──> [ send_email ]
                                     │
                                     └──false──> [ run_job ]

Why

n8n is great, but it lives outside your app. Flowforge keeps the automation inside your Laravel codebase: your models, your queue, your jobs, your tests. No separate service to host, no JSON exported from a visual editor that nobody can code-review. You define flows in PHP, commit them, and run them like any other part of the app.

Requirements

  • PHP 8.2 or 8.3
  • Laravel 11 or 12

Install

composer require imtiaz-hasan/flowforge

Publish and run the migrations:

php artisan vendor:publish --tag=flowforge-migrations
php artisan migrate

Optionally publish the config:

php artisan vendor:publish --tag=flowforge-config

Your first workflow in 5 lines

use Flowforge\Facades\Flowforge;

Flowforge::define('welcome')
    ->webhook()
    ->email('greet', ['to' => '{{ trigger.email }}', 'subject' => 'Welcome', 'body' => 'Glad you are here.'])
    ->save();

That registers a webhook-triggered workflow. POST to /flowforge/webhooks/welcome with {"email": "ada@example.com"} and Flowforge starts a run on your queue, sends the email, and records the result.

Run a stored workflow by hand:

Flowforge::run('welcome', ['email' => 'ada@example.com']);

How it works

A workflow is a set of nodes keyed by id. Each node has a type, a config, and a link to the next node. The engine starts at the first node and walks the graph:

  1. Resolve the node's config, filling in {{ placeholders }} from earlier output.
  2. Run the node, retrying up to its configured number of attempts.
  3. Write a node-run record (status, input, output, error, timing).
  4. Follow the node's link, or a condition's branch, to the next node.

The whole run is one queued RunWorkflowJob that walks the graph. A delay node saves a cursor and re-dispatches the job on a delay, so a flow can wait without holding a worker.

Passing data between nodes

Any node's output is available to later nodes by id. Use {{ trigger.x }} for the payload that started the run, and {{ nodeId.field }} for an earlier node's output.

Flowforge::define('lookup')
    ->http('user', ['method' => 'GET', 'url' => 'https://api.example.com/users/{{ trigger.id }}'])
    ->email('notify', [
        'to' => '{{ user.body.email }}',
        'subject' => 'Your profile',
        'body' => 'Hello {{ user.body.name }}',
    ])
    ->save();

A lone placeholder such as {{ trigger.id }} keeps its original type (int, array, bool). A placeholder inside a longer string is interpolated as text.

Branching

condition() takes a comparison and two closures, one for each branch. The first node added inside a closure is that branch's entry point.

Flowforge::define('route-by-plan')
    ->condition(
        'is_pro',
        ['left' => '{{ trigger.plan }}', 'operator' => '==', 'right' => 'pro'],
        fn ($flow) => $flow->email('pro', ['to' => '{{ trigger.email }}', 'subject' => 'Pro', 'body' => 'Thanks for going pro.']),
        fn ($flow) => $flow->email('free', ['to' => '{{ trigger.email }}', 'subject' => 'Welcome', 'body' => 'Enjoy the free plan.']),
    )
    ->save();

Operators: ==, ===, !=, >, >=, <, <=, in, contains, empty, not_empty.

Triggers

Trigger Builder Starts a run when
Webhook ->webhook($secret = null) a request hits /{prefix}/{key}
Schedule ->schedule('0 * * * *') the cron expression is due
Model event ->onModel(User::class, ['created']) a model fires that Eloquent event
Manual ->manual() you call Flowforge::run() or the artisan command

For model triggers, add the TriggersFlowforge trait to the model:

use Flowforge\Triggers\TriggersFlowforge;

class User extends Model
{
    use TriggersFlowforge;
}

Webhooks can require a shared secret. Pass it to ->webhook('my-secret') and send it as the X-Flowforge-Secret header (or a secret query parameter). A mismatch returns 403.

Built-in nodes

Type Builder What it does
http_request ->http($id, $config) Sends an HTTP request with configurable method, headers, query, and JSON body. Output: status, ok, body, headers.
send_email ->email($id, $config) Sends a plain-text email (to, subject, body).
run_job ->runJob($id, $config) Dispatches a queued job class with a payload array.
transform ->transform($id, $set) Builds a new output array, resolving placeholders. Useful for shaping data.
condition ->condition($id, $config, $true, $false) Compares two values and branches.
delay ->delay($id, $seconds) Pauses the run, then resumes from where it left off.
loop ->loop($id, $items, $type, $config) Runs one inner node once per item in a list.

The http_request node also accepts an auth block:

->http('call', [
    'url' => 'https://api.example.com/me',
    'auth' => ['type' => 'bearer', 'token' => '{{ trigger.token }}'],
    // or ['type' => 'basic', 'username' => '...', 'password' => '...']
])

The send_email node sends plain text by default, or HTML when you set html, and accepts cc, bcc, from, and reply_to.

Looping over a list

Flowforge::define('import')
    ->loop('rows', '{{ trigger.users }}', 'http_request', [
        'method' => 'POST',
        'url' => 'https://api.example.com/users',
        'json' => ['name' => '{{ item.name }}', 'position' => '{{ index }}'],
    ])
    ->save();

Inside the loop config, {{ item }} is the current element and {{ index }} is its position. The loop runs in order, in the same process, and collects each run's output under results. For very large lists, fan out with queued jobs instead.

Per-node error handling

Each node config accepts:

  • retries: how many extra attempts on failure (defaults to flowforge.node_retries).
  • continue_on_error: if true, a failed node is logged and the workflow keeps going instead of stopping.

Custom nodes

This is where Flowforge earns its keep. A node is any class that implements the Node contract. Scaffold one with php artisan make:flowforge-node SlackNode, or write it by hand:

use Flowforge\Contracts\Node;
use Flowforge\Engine\NodeContext;
use Flowforge\Engine\NodeResult;

class SlackNode implements Node
{
    public function handle(NodeContext $context, array $config): NodeResult
    {
        Http::post($config['webhook_url'], ['text' => $config['text']]);

        return NodeResult::ok(['posted' => true]);
    }
}

Register it (in a service provider's boot):

Flowforge::registerNode('slack', SlackNode::class);

Now use it in any workflow:

Flowforge::define('alert')
    ->node('ping', 'slack', ['webhook_url' => config('services.slack.hook'), 'text' => 'Deploy finished'])
    ->save();

The engine resolves placeholders in $config before calling handle(), so your node receives plain values. Return NodeResult::ok($output) to continue, NodeResult::branch('true', $output) to branch, or NodeResult::pause($seconds, $output) to wait.

Storing workflows as data

The fluent builder is one way in. You can also store a workflow as an array or JSON and load it from the database. The structure is the same either way: a start node id and a map of nodes, each with type, config, and next (or next_true / next_false for conditions).

Events

The engine fires events you can listen for, so logging, metrics, and alerting stay out of the engine:

  • Flowforge\Events\WorkflowStarted (run begins, not on a resume)
  • Flowforge\Events\NodeCompleted (each node finishes, success or failure)
  • Flowforge\Events\WorkflowCompleted (run finishes successfully)
  • Flowforge\Events\WorkflowFailed (a node failed and did not continue on error)
Event::listen(WorkflowFailed::class, function ($event) {
    Log::error("Workflow {$event->run->workflow_key} failed at node {$event->nodeId}: {$event->error}");
});

Read-only UI

Flowforge ships an optional read-only UI that lists workflows and shows each run node by node. It is off by default. Turn it on in config/flowforge.php (or set FLOWFORGE_UI_ENABLED=true) and visit /flowforge.

Run logs can hold payloads and email addresses, so the UI is guarded. With no rule set, only the local environment may view it. Define who can see it in production from a service provider's boot:

use Flowforge\Flowforge;

Flowforge::auth(fn ($request) => $request->user()?->isAdmin());

Reliability

  • Validation. Definitions are checked on save: a missing start node, a link to a node that does not exist, an unknown node type, or a cycle is rejected with a clear error instead of failing mid-run.
  • Per-node retries. Set retries on a node, or a default with flowforge.node_retries.
  • Failed runs. A run that fails records the error and the node it failed at. Resume it from that node with flowforge:retry.
  • Stuck runs. If a worker dies hard, the run is swept up by flowforge:reap after flowforge.run_timeout_minutes. Schedule it to run regularly.

Artisan commands

php artisan flowforge:list                       # list stored workflows
php artisan flowforge:run {key} --payload='{}'   # run one by key
php artisan flowforge:demo                        # create a side-effect-free demo workflow
php artisan flowforge:retry {run}                 # retry a failed run from the node that failed
php artisan flowforge:reap --minutes=15           # fail runs stuck running past the timeout
php artisan flowforge:prune --days=30             # delete old runs (add --keep-failed to keep failures)
php artisan make:flowforge-node SlackNode         # scaffold a custom node class

Configuration

config/flowforge.php covers the queue connection and name, the default node retry count, the run timeout used by flowforge:reap, HTTP client timeouts and retries, the webhook route prefix and middleware, and the UI toggle, prefix, and middleware. Every secret comes from the environment. Nothing is hardcoded.

Testing

The package is tested with Orchestra Testbench on an in-memory SQLite database. External calls are faked.

composer install
vendor/bin/phpunit

The suite needs the pdo_sqlite extension. CI runs it on PHP 8.2 and 8.3 against Laravel 11 and 12.

Roadmap

  • A visual flow editor for the UI (read-only today).
  • More built-in nodes (database write, queue fan-out).
  • A UI for model-event triggers.
  • Sub-workflows (a node that runs another workflow).
  • Parallel branches that run concurrently.

License

MIT. See LICENSE.