April 19, 2026announcementenginenodejs

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

bash
npm install symflow

The 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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() — 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:

typescript
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:

  1. guard — is the transition allowed?
  2. leave — per source place, before tokens are removed
  3. transition — after tokens are removed from source places
  4. enter — per target place, before marking is updated
  5. entered — after marking is updated
  6. completed — after the full transition is done
  7. announce — per newly enabled transition
typescript
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:

typescript
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:

typescript
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:

typescript
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:

yaml
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:

typescript
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.

typescript
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.