Introducing @symflow/core: 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/core: 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 @symflow/coreThe 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/core";
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/core";
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/core";
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()` — calls
subject.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/core";
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 { exportYaml, importYaml } from "@symflow/core/yaml";
import { exportJson, importJson } from "@symflow/core/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/core to run it in production.