Playable Scenarios: Walk Through a Workflow Like You're Publishing an Article
A simulator that only knew states was only half a simulator
The original SymFlowBuilder simulator could fire transitions, track active places, evaluate guards, and replay Symfony events. What it could not do was answer the most common question someone asks of a workflow diagram:
> "But what does the data look like at each step?"
A workflow is rarely just a graph. It is a graph plus a thing moving through it — an article on its way to publication, an order on its way to fulfillment, a moderation case waiting on a reviewer. Without that thing, the simulator was an abstract token-pusher. You could see that approved was reachable from submitted. You could not see what an approved article actually looked like.
This release fixes that. The simulator now carries a subject — the JSON object your workflow operates on — and lets each transition mutate it as it fires. You can also attach a fake HTTP request to any transition and see exactly what would have hit your backend. The result feels less like watching a state machine and more like clicking through an n8n run.
The article-publishing example, end to end
Open the editor, build a state machine with five places — draft, pending_review, approved, rejected, published — and four transitions: submit_for_review, approve, reject, publish. Click Simulate.
The simulator panel now has three tabs: Steps, Scenario, and Inspector. Open Scenario and click the Publishing an article template card.
The starting subject lands on the page:
{
"id": "art_1042",
"title": "My first post",
"body": "Hello, world.",
"author": "alice",
"reviewer": null,
"reviewNotes": null,
"publishedAt": null
}Below it, four transition cards. Each one has patches that mutate the subject and a mock HTTP request that would have fired:
submit_for_review
patches: set reviewer = "bob"
request: POST /api/articles/{{id}}/submit
{ "reviewer": "bob" }
→ 202 { "id": "{{id}}", "status": "pending_review" }Switch to Steps. Click submit_for_review. The article is now in pending_review and the reviewer is bob. Click approve. Then publish. The history list shows each step with a colored POST badge — visual proof that a request would have fired.
Click any history row. The panel jumps to Inspector and shows you, side by side, what the article looked like before and after that transition, with the changed fields highlighted. Below that: the resolved mock request — POST /api/articles/art_1042/publish (the {{id}} was substituted with the real subject value at the moment of the transition) and a 200 response with a real-looking publish URL.
That is the moment the feature clicks. You are not reading a YAML file or staring at boxes connected by arrows. You are watching an article get reviewed and published, with the API calls a real backend would make plotted out next to it.
What you can declare per transition
Each transition can carry an optional effect with three pieces, all of them optional:
1. A description
A one-line note about what the transition represents. Shown in the Inspector at the top of the step.
2. Patches that mutate the subject
A list of { op, path, value } operations applied to a deep-cloned subject when the transition fires. Three ops are supported:
set— write a value at the path. Path syntax isfield.nested.deeperoritems[0].name.push— append a value to an array at the path.remove— delete a key or splice an array element.
Patches are interpreted client-side, in pure JavaScript, on a cloned subject. Nothing escapes the simulator. The original subject in the scenario is your "starting state" — re-running the simulator always rewinds to it.
3. A mock HTTP request
A pretend request the transition would have fired, with method, URL, optional body, and an optional canned response with status + body. URLs and JSON values support {{ subject.path }} interpolation, so:
POST /api/articles/{{id}}/publish…becomes POST /api/articles/art_1042/publish when fired. The substitution happens once, at the moment of the transition, using the subject as it was before the patches ran. That snapshot is frozen into the step's history — even if you patch id later, the historical request keeps the value it had at the time.
No actual network call is made. The mock request is a UI artifact. That is intentional: scenarios are for teaching, reviewing, and demoing a workflow, not for testing your backend.
Three tabs, one mental model
- Steps is the playback surface. Active places, available transitions, history. Click history to jump to Inspector.
- Scenario is the design surface. Subject editor, per-transition patches and mock requests, template gallery. Edits persist with the workflow when you save.
- Inspector is the what just happened surface. Before/after subject diff for the selected step, with changed paths highlighted, plus the resolved mock request and response.
The footer carries the playback controls — Step Back, Restart, Auto-play with a configurable interval, and a stop. The header has tooltipped icons for restart and close.
Persistence and sharing
A scenario is stored alongside the workflow in a new simulationConfig JSONB column. It travels with the workflow:
- Auto-save picks up scenario edits and writes them to the cloud (signed-in users) or localStorage (guests), debounced 2 seconds.
- The public share view at
/w/[shareId]loads the scenario when the share owner has saved one. Anyone visiting the link can hit Simulate and walk the article through the workflow without an account. - The iframe embed at
/embed/[shareId]is now interactive. Drop a<iframe>into a docs page, add?play=1, and the scenario auto-starts. Readers click through it the same way they click through an n8n demo on the n8n website.
<iframe
src="https://symflowbuilder.com/embed/abc123?play=1&minimap=0&branding=0"
width="100%"
height="560"
style="border:0;border-radius:14px"
loading="lazy"
title="Article publishing flow"
></iframe>That iframe is a runnable demo of your workflow. Embed it in your README, your docs site, your design review document — wherever explaining the flow has so far required either a screenshot or an awkward "let me share my screen" moment.
What this is *not*
A few deliberate non-goals, since people will ask:
- Not a backend mock. No request leaves the browser. Mock responses are static — no scripting, no JavaScript evaluation. If you need a real mock backend, MSW is what you want; this is a teaching surface.
- Not in the Symfony YAML export. Scenarios are simulator-only and live in their own column. Your exported YAML stays a clean Symfony workflow definition that drops into
config/packages/workflow.yamlwithout surprises. - Not a replacement for tests. It is excellent for showing a flow. It is not a substitute for the integration tests that pin your guards in production.
How it works under the hood
The data model is small enough to describe in one paragraph. SimulationConfig is { subject, effects, templateId }. Each effect is { description?, patches?, mockRequest? }. The simulator store carries the subject as live state, deep-clones it on initialization, and on each apply() runs the matching effect's patches against a clone — preserving the before snapshot on the step. The mock request is resolved with a tiny {{ path }} interpolator that walks the subject via JSON-pointer-style segments and substitutes string and JSON values. Pure functions, fully sandboxed, no eval.
The whole thing is built on the same `symflow` engine that powers Symfony-compatible runtime. The marking is still a Symfony marking. The events still fire in Symfony's order. The subject is a layer the simulator adds on top — the engine itself is unchanged.
Try it
- Open the editor and either build a workflow or load an existing one.
- Click Simulate.
- Switch to the Scenario tab and click Publishing an article.
- Switch back to Steps and walk
submit_for_review→approve→publish. - Click any history row.
If you have already shared a workflow publicly, open its /w/[shareId] page — your viewers can now play through whatever scenario you saved.
What is next
The natural follow-ups are user-supplied scenarios via share links (?scenario=template-id to override the saved one), per-place initial-state branches (so you can simulate "what if this article started in pending_review?"), and a record-and-replay button that captures a real run from your app and lets you scrub through it. Open issues if any of those would help.
Until then: design the workflow, save the scenario, share the link, watch your reviewer click through it without ever opening a YAML file.