April 30, 2026tutorialsimulatorscenariosarticlewalkthrough

Walkthrough: Building the 'Publishing an Article' Scenario from Scratch

What we are building

A blog post going through editorial review. Five places — draft, pending_review, approved, rejected, published — and four transitions. By the end you will be able to click through the simulator and watch an article object evolve from a draft with no reviewer to a published post with a public URL, with the API calls a real backend would make plotted out beside it.

This is the workflow shipped as the Publishing an article template. We are going to rebuild it from scratch, slowly, so each piece earns its place.

Step 1 — Draw the graph

Open the editor and drag five state nodes onto the canvas. Name them:

  • draft — initial state
  • pending_review
  • approved
  • rejected
  • published

Mark draft as the initial state in the properties panel. This becomes initial_marking: draft in the exported YAML.

Now connect them with four transitions:

  • submit_for_reviewdraftpending_review
  • approvepending_reviewapproved
  • rejectpending_reviewrejected
  • publishapprovedpublished

Set the workflow type to state_machine (the article is in exactly one place at a time) and set property: status on the marking store — that is the field name on your domain object that holds the current state.

At this point you have a working state machine. Click Simulate in the toolbar and you can already step through it. But every transition does the same thing: shift a token between abstract place names. That is not very interesting yet.

Step 2 — Open the Scenario tab

The simulator sheet on the right has three tabs: Steps, Scenario, Inspector. Switch to Scenario.

This is where the article lives. The starting subject — the JSON object your workflow operates on — needs to describe what an article actually looks like in your system. Paste this in:

json
{
    "id": "art_1042",
    "title": "My first post",
    "body": "Hello, world.",
    "author": "alice",
    "reviewer": null,
    "reviewNotes": null,
    "publishedAt": null
}

Notice there is no status field. You do not need one — because you set property: status on the workflow, the simulator automatically writes the current marking into subject.status after every step. State machines write a single string ("draft"); workflow-type Petri nets write an array of place names. This mirrors what Symfony's marking store does in production.

So at t=0, your live subject is actually:

json
{
    "id": "art_1042",
    "title": "My first post",
    "body": "Hello, world.",
    "author": "alice",
    "reviewer": null,
    "reviewNotes": null,
    "publishedAt": null,
    "status": "draft"
}

That is what shows up in the Inspector.

Step 3 — Write the patches

Below the subject editor is a card per transition. Each card has a + Patch button and a + Request button. Patches mutate the subject when the transition fires.

A patch has three pieces: an op, a path, and a value.

The op is one of three things:

  • set — assign a value at the path. Replaces whatever was there. *You will use this almost every time.* Example: set reviewer = "bob" becomes subject.reviewer = "bob".
  • push — append to an array at the path. Creates the array if missing. Example: push history "submitted" becomes subject.history.push("submitted").
  • del — delete the field entirely. Example: del reviewer becomes delete subject.reviewer. Different from set reviewer = nulldel removes the key from the object.

The path is a JSON-pointer-style string: field, nested.field, or items[0].name for arrays. The value is anything JSON. Strings, numbers, booleans, arrays, nested objects.

Now configure the four transitions:

`submit_for_review`

The author submits the draft. A reviewer gets assigned.

text
+ Patch  set  reviewer  "bob"

`approve`

The reviewer signs off.

text
+ Patch  set  reviewNotes  "LGTM"

`reject`

The reviewer asks for changes. Notes get set, reviewer gets cleared so a new one can be assigned next round.

text
+ Patch  set  reviewNotes  "Needs rework"
+ Patch  set  reviewer     null

(You could use del reviewer instead — same end-result if you do not care about the field existing.)

`publish`

The article goes live. We stamp a publish timestamp.

text
+ Patch  set  publishedAt  "2026-04-30T10:00:00.000Z"

Step 4 — Walk through it

Switch back to Steps. Click submit_for_review. Two things happen:

  1. The marking advances from draft to pending_review. The state node on the canvas glows green; the previous one dims.
  2. The subject mutates: reviewer becomes "bob" and status becomes "pending_review".

You can see the second part directly: open Inspector and you get a side-by-side diff of the subject before and after that step. Changed fields are highlighted. reviewer flips from null to "bob". status flips from "draft" to "pending_review".

Click approve, then publish. The article walks the happy path. If you go back and pick reject instead, you can watch the alternate branch play out — reviewNotes gets set to "Needs rework", reviewer clears to null, the article ends up in rejected.

This is the moment scenarios stop being abstract. You are not reading a YAML file. You are watching an article move through a process.

Step 5 — Add mock API calls

Most workflows are not just state machines — each transition typically corresponds to a real API call. submit_for_review posts to /api/articles/.../submit. publish posts to /api/articles/.../publish. Real listeners fire those requests in production.

In the simulator you can attach a *mock* request to each transition — a fake HTTP call that the Inspector will display as if it had fired. No real network goes out. It is a teaching surface, not a backend mock.

Click + Request on the submit_for_review card. The editor expands inline:

  • Method: POST
  • URL: /api/articles/{{id}}/submit
  • Request body:

`json

{ "reviewer": "bob" }

`

  • Status: 202
  • Response body:

`json

{ "id": "{{id}}", "status": "pending_review" }

`

Notice the {{id}} interpolation. The simulator substitutes {{ subject.path }} references with the actual subject value at the moment the transition fires. So when you click submit_for_review, the Inspector shows POST /api/articles/art_1042/submit — the {{id}} gets resolved against the live subject. Substitution works in URLs and in JSON values.

Add similar requests for the other three transitions:

  • approvePOST /api/articles/{{id}}/approve returning 200 { "id": "{{id}}", "status": "approved" }
  • rejectPOST /api/articles/{{id}}/reject returning 200 { "id": "{{id}}", "status": "rejected" }
  • publishPOST /api/articles/{{id}}/publish returning 200 { "id": "{{id}}", "status": "published", "url": "https://example.com/blog/{{id}}" }

Now walk the scenario again. Click any step in the history. The Inspector shows the subject diff *and* the resolved mock request — method, URL, body, status, response. The substituted values are frozen into the step's history at the moment of the transition; if you patch id later, the historical request keeps the value it had at the time.

Step 6 — Save and share

When you stop the simulator (or it auto-saves), the scenario gets persisted to the workflow alongside the graph. Reload the page and the scenario comes back. Sign in and the scenario rides up to the cloud with the workflow.

Open the share dialog and make the workflow public. Anyone visiting /w/[shareId] can hit Simulate and walk through your scenario without signing in. They get the article-publishing experience exactly as you designed it.

For docs sites, add an iframe:

html
<iframe
  src="https://symflowbuilder.com/embed/<shareId>?play=1&minimap=0&branding=0"
  width="100%"
  height="560"
  style="border:0;border-radius:14px"
  loading="lazy"
  title="Publishing an article"
></iframe>

The ?play=1 query param makes the embed start the simulator automatically — readers land on a runnable demo, not a static diagram.

What is actually persisting

Under the hood, your scenario is a small JSON blob saved on the workflow:

json
{
    "subject": {
        "id": "art_1042",
        "title": "My first post",
        "...": "..."
    },
    "effects": {
        "submit_for_review": {
            "patches": [{ "op": "set", "path": "reviewer", "value": "bob" }],
            "mockRequest": {
                "method": "POST",
                "url": "/api/articles/{{id}}/submit",
                "body": { "reviewer": "bob" },
                "response": { "status": 202, "body": { "id": "{{id}}", "status": "pending_review" } }
            }
        },
        "approve": { "...": "..." },
        "reject":  { "...": "..." },
        "publish": { "...": "..." }
    }
}

It lives in a separate simulationConfig column on the workflow row in PostgreSQL. Crucially, it never enters the Symfony YAML export. Your exported config/packages/workflow.yaml stays a clean Symfony workflow definition — places, transitions, guards, metadata. The scenario is a layer on top, scoped to the simulator.

That separation is intentional. Scenarios are for explaining a workflow to a teammate or stakeholder; the YAML is for running it in production. Different audiences, different artifacts.

Why this is worth your time

Half of every workflow review I have ever sat in starts with someone drawing on a whiteboard while saying "okay so an article comes in..." and then mutating a fictional object verbally. Scenarios put that conversation into the tool. Your reviewer no longer needs to imagine the article — they can click through the actual state machine with real-looking data and see, at each step, what the article looks like and what API the system would call.

Hand someone a workflow link and they will read the diagram. Hand them a workflow link with a scenario and they will *use* it. That is the difference.

Try it now

The full template is one click away if you do not want to build it from scratch:

  1. Open the editor
  2. Drag five places and four transitions matching the layout above
  3. Click Simulate, switch to Scenario, click Publishing an article
  4. Walk it through

Or fork the existing public scenario and modify it — change the reviewer to your own team's names, swap the URL prefix to your real backend, add a tags array and push to it on each transition. The whole point of scenarios is that the data fits *your* domain, not ours.