Introducing symflow: A Symfony-Compatible Workflow Engine for Node.js
Why We Built Our Own Engine
SymFlowBuilder started as a visual editor that exports YAML for Symfony. But we needed something to power the simulator — a runtime that could fire transitions, track markings, evaluate guards, and emit events in the exact same order Symfony does.
So we built symflow: a standalone TypeScript workflow engine with zero framework dependencies. It runs anywhere JavaScript runs — Node.js backends, serverless functions, CLI tools, or the browser.
Installation
npm install symflowThe package ships ESM and CJS builds with full TypeScript types.
Defining a Workflow
A workflow definition is a plain object — no decorators, no config files:
import type { WorkflowDefinition } from "symflow/engine";
const orderWorkflow: WorkflowDefinition = {
name: "order",
type: "state_machine",
places: [
{ name: "draft" },
{ name: "submitted" },
{ name: "approved" },
{ name: "rejected" },
{ name: "fulfilled" },
],
transitions: [
{ name: "submit", froms: ["draft"], tos: ["submitted"] },
{ name: "approve", froms: ["submitted"], tos: ["approved"], guard: "subject.total < 10000" },
{ name: "reject", froms: ["submitted"], tos: ["rejected"] },
{ name: "fulfill", froms: ["approved"], tos: ["fulfilled"] },
],
initialMarking: ["draft"],
};Two types are supported:
state_machine— exactly one active place at a time (linear or branching flows)workflow— multiple places can be active simultaneously (Petri net with AND-split / AND-join)
Using the Standalone Engine
The WorkflowEngine class manages a marking (the current state) directly:
import { WorkflowEngine } from "symflow/engine";
const engine = new WorkflowEngine(orderWorkflow);
// Check the current state
engine.getActivePlaces(); // ["draft"]
engine.getEnabledTransitions(); // [{ name: "submit", ... }]
// Check if a transition can fire
const result = engine.can("submit");
result.allowed; // true
result.blockers; // []
// Fire a transition
engine.apply("submit");
engine.getActivePlaces(); // ["submitted"]
// Reset to initial marking
engine.reset();If a transition cannot fire, apply() throws with a descriptive error. Use can() first to check safely — it returns structured blockers explaining why:
const result = engine.can("fulfill");
// { allowed: false, blockers: [{ code: "not_in_place", message: "..." }] }Subject-Driven Workflows (Like Symfony)
For real applications you typically want the workflow state stored on your domain objects. The Workflow class mirrors Symfony's Workflow service — pass your entity to can() and apply(), and the marking is read from and written back to the subject automatically:
import { createWorkflow, propertyMarkingStore } from "symflow/subject";
interface Order {
id: string;
total: number;
status: string;
}
const workflow = createWorkflow<Order>(orderWorkflow, {
markingStore: propertyMarkingStore("status"),
});
const order: Order = { id: "ord_123", total: 5000, status: "draft" };
workflow.can(order, "submit"); // { allowed: true, blockers: [] }
workflow.apply(order, "submit");
console.log(order.status); // "submitted"Marking Stores
Two built-in marking stores match Symfony's options:
propertyMarkingStore("field")— reads/writes a property directly (string for single place, string[] for parallel)methodMarkingStore()— callssubject.getMarking()/subject.setMarking(), configurable via options
Guards
Guards are expressions attached to transitions. You provide an evaluator function that decides whether the expression passes:
const workflow = createWorkflow<Order>(orderWorkflow, {
markingStore: propertyMarkingStore("status"),
guardEvaluator: (expression, { subject }) => {
if (expression === "subject.total < 10000") {
return subject.total < 10000;
}
return true;
},
});
const bigOrder: Order = { id: "ord_456", total: 50000, status: "submitted" };
workflow.can(bigOrder, "approve");
// { allowed: false, blockers: [{ code: "guard_blocked", message: "..." }] }The evaluator is fully pluggable — you can integrate role checks, feature flags, or any custom logic.
Events
The engine fires events in the exact same order as Symfony's Workflow component:
- guard — is the transition allowed?
- leave — per source place, before tokens are removed
- transition — after tokens are removed from source places
- enter — per target place, before marking is updated
- entered — after marking is updated
- completed — after the full transition is done
- announce — per newly enabled transition
workflow.on("entered", (event) => {
console.log(`Order ${event.subject.id} entered via "${event.transition.name}"`);
console.log("New marking:", event.marking);
});
workflow.on("guard", (event) => {
console.log(`Checking guard for "${event.transition.name}"`);
});Each listener receives the event type, the transition, the current marking, the workflow name, and (for subject-driven workflows) the subject itself.
Validation
Validate your definition before creating an engine to catch structural problems early:
import { validateDefinition } from "symflow/engine";
const result = validateDefinition(orderWorkflow);
if (!result.valid) {
for (const error of result.errors) {
console.error(`[${error.type}] ${error.message}`);
}
}The validator catches:
- Missing or invalid initial markings
- Transitions referencing non-existent places
- Unreachable places (BFS from initial marking)
- Dead transitions (source places are unreachable)
- Orphan places (no incoming or outgoing transitions)
Parallel Workflows (Petri Net)
Switch to type: "workflow" to enable AND-split and AND-join patterns:
const reviewWorkflow: WorkflowDefinition = {
name: "article_review",
type: "workflow",
places: [
{ name: "draft" },
{ name: "checking_content" },
{ name: "checking_spelling" },
{ name: "content_approved" },
{ name: "spelling_approved" },
{ name: "published" },
],
transitions: [
{ name: "start_review", froms: ["draft"], tos: ["checking_content", "checking_spelling"] },
{ name: "approve_content", froms: ["checking_content"], tos: ["content_approved"] },
{ name: "approve_spelling", froms: ["checking_spelling"], tos: ["spelling_approved"] },
{ name: "publish", froms: ["content_approved", "spelling_approved"], tos: ["published"] },
],
initialMarking: ["draft"],
};
const engine = new WorkflowEngine(reviewWorkflow);
engine.apply("start_review");
engine.getActivePlaces(); // ["checking_content", "checking_spelling"]
engine.apply("approve_content");
engine.can("publish"); // { allowed: false } — spelling not approved yet
engine.apply("approve_spelling");
engine.can("publish"); // { allowed: true } — both paths complete
engine.apply("publish");
engine.getActivePlaces(); // ["published"]YAML / JSON Import and Export
The package also includes utilities to convert between workflow definitions and Symfony-compatible YAML or JSON:
import { exportWorkflowYaml, importWorkflowYaml } from "symflow/yaml";
import { exportWorkflowJson, importWorkflowJson } from "symflow/json";
// Export to Symfony YAML
const yaml = exportYaml(orderWorkflow);
// Import from existing Symfony config
const definition = importYaml(yamlString);Real-World Example: Symfony Article Workflow
Here is a real Symfony workflow YAML — the classic article review pipeline:
framework:
workflows:
article_workflow:
type: 'workflow'
marking_store:
type: 'method'
property: 'marking'
supports:
- App\Entity\Article
initial_marking: NEW_ARTICLE
places:
NEW_ARTICLE:
CHECKING_CONTENT:
metadata:
bg_color: ORANGE
CONTENT_APPROVED:
metadata:
bg_color: DeepSkyBlue
CHECKING_SPELLING:
metadata:
bg_color: ORANGE
SPELLING_APPROVED:
metadata:
bg_color: DeepSkyBlue
PUBLISHED:
metadata:
bg_color: Lime
transitions:
CREATE_ARTICLE:
from: [NEW_ARTICLE]
to: [CHECKING_CONTENT, CHECKING_SPELLING]
APPROVE_CONTENT:
from: [CHECKING_CONTENT]
to: [CONTENT_APPROVED]
APPROVE_SPELLING:
from: [CHECKING_SPELLING]
to: [SPELLING_APPROVED]
PUBLISH:
from: [CONTENT_APPROVED, SPELLING_APPROVED]
to: [PUBLISHED]Import it directly with symflow and run the engine:
import { readFileSync } from "fs";
import { importWorkflowYaml } from "symflow/yaml";
import { WorkflowEngine } from "symflow/engine";
const yaml = readFileSync("article_workflow.yaml", "utf8");
const { definition } = importWorkflowYaml(yaml);
const engine = new WorkflowEngine(definition);
engine.apply("CREATE_ARTICLE");
engine.getActivePlaces(); // ["CHECKING_CONTENT", "CHECKING_SPELLING"]
engine.apply("APPROVE_CONTENT");
engine.apply("APPROVE_SPELLING");
engine.apply("PUBLISH");
engine.getActivePlaces(); // ["PUBLISHED"]CREATE_ARTICLE is an AND-split — it forks into two parallel checks. PUBLISH is an AND-join — both content and spelling must be approved before the article can go live.
State Machine Example: Blog Publishing
Not every workflow needs parallel states. This blog publishing flow uses type: state_machine — exactly one state active at a time, with branching paths for approval and rejection.
The importer also handles Symfony's !php/const YAML tags — constants like !php/const App\\Workflow\\State\\BlogState::NEW_BLOG are resolved to "NEW_BLOG" automatically.
import { readFileSync } from "fs";
import { importWorkflowYaml } from "symflow/yaml";
import { WorkflowEngine } from "symflow/engine";
const yaml = readFileSync("blog_event.yaml", "utf8");
const { definition } = importWorkflowYaml(yaml);
const engine = new WorkflowEngine(definition);
// Happy path: create → check → review → publish
engine.apply("CREATE_BLOG");
engine.apply("VALID");
engine.apply("PUBLISH");
engine.getActivePlaces(); // ["PUBLISHED"]
// Unpublish and update cycle
engine.apply("NEED_REVIEW");
engine.apply("REJECT");
engine.apply("UPDATE");
engine.getActivePlaces(); // ["NEED_REVIEW"]From CHECKING_CONTENT, the blog can go to NEED_REVIEW (valid) or NEED_UPDATE (invalid). Published articles can be pulled back to review. Rejected articles go through an update cycle until approved.
What This Means for You
If you are building a Node.js application that needs structured state management — order pipelines, approval flows, content publishing, onboarding funnels — you can now use the same workflow semantics as Symfony without running PHP.
Design your workflow visually in SymFlowBuilder, test it with the simulator, then use symflow to run it in production.