imtiaz-hasan / flowforge
Flowforge - an n8n-style workflow automation engine for Laravel. Define triggers, actions, and branching flows in pure PHP.
Requires
- php: ^8.2|^8.3
- illuminate/bus: ^11.0|^12.0
- illuminate/console: ^11.0|^12.0
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/queue: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
This package is auto-updated.
Last update: 2026-07-08 15:25:52 UTC
README
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:
- Resolve the node's config, filling in
{{ placeholders }}from earlier output. - Run the node, retrying up to its configured number of attempts.
- Write a node-run record (status, input, output, error, timing).
- 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 toflowforge.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
retrieson a node, or a default withflowforge.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:reapafterflowforge.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.