PHP runtime for fancy-flow workflow graphs. The framework-free twin of @particle-academy/fancy-flow's TypeScript engine. CLAUDE.md symlinks here.
php artisan boost:add-skill particle-academy/fancy-flow-php
Save this content to: AGENTS.md
# AGENTS.md — fancy-flow-php
PHP runtime for `fancy-flow` workflow graphs. The framework-free twin of
`@particle-academy/fancy-flow`'s TypeScript engine. `CLAUDE.md` symlinks here.
## What this package is
A faithful **port**, not a redesign. The source of truth for behavior is
`@particle-academy/fancy-flow` (in the envelope at `repos/fancy-flow`): its
`src/runtime/run-flow.ts`, `src/schema/workflow-schema.ts`, `src/registry/*`.
When those change, this mirror tracks them kind-for-kind via the parity fixtures.
The guarantee: **same `WorkflowSchema` JSON in, same `RunResult.outputs` out** on
Node and PHP. Don't break it.
## Architecture (framework-free core)
- `Workflow` — import/export/validate WorkflowSchema v1. **`lenient` softens
vocabulary (an unknown kind), never the schema version:** a document that is
not `version: 1` after migration is refused in every mode with an empty graph,
exactly as the TS and Python importers refuse it. Anything that imports in
order to RUN must check `ImportResult::refused()` — `toGraph()` and the
`subgraph` executor throw `UnreadableWorkflow`, `EloquentWorkflowResolver`
returns a failure — or a refused document runs as an empty graph and reports
success. Refuse on `refused()`, not on `! ok`: a graph that was read but
carries a connectivity error has always run, and parity fixture 03 pins it.
- `Engine\FlowRunner` — the `runFlow` port (Kahn topo, ports, branching, cycles,
timeout). **Note:** a node runs when ≥1 incoming edge is active (merge-after-
decision, `#1`), and `collectInputs` reads only active edges — that's the
documented contract; don't regress either.
- `NodeKindRegistry` + `Registry\{NodeKind, ConfigField, Builtin}` — the kinds.
- `ExecutorRegistry` + `Contracts\{NodeExecutor, Resolver}` — behavior; resolves
node id → kind → `*`.
- `Contracts\TriggerGuard` — the precondition a cohort run re-checks just before
it starts. Fails CLOSED by design; see `Laravel\TriggerCohort`.
- `Runtime\{ExecutionContext, RunEvent, RunOptions, RunResult, Port, RunIdentity, ...}`.
**`RunIdentity` is what a WRITING node keys an idempotent call on.**
`$ctx->run?->stepKey($ctx->node->id)` is the same on every retry of one step
and different for every other execution of the same node — which is why
`attempt` is carried on it and is NOT part of the key. `(run, node)` alone is
insufficient: a node runs once per subflow invocation and once per loop
iteration, so the key composes the invocation `path` and an optional
`occurrence`. `isReplaySafe()` says whether a retry is still inside the
provider's dedup window; past it the caller must REFUSE, because resending
the key and minting a fresh one both write twice. Pinned by
`shared/flow-run-identity`; the other two runtimes implement the same table.
- `Nodes\<Domain>\*Executor` — the 26 default executors, grouped by domain.
- `Nodes\Support\*` — injectable client interfaces + deterministic fakes + the
`Expr` `{{ path }}` resolver (safe, no arbitrary eval).
- `Capabilities\*` — the HOST capability seam: `LlmClient` (`chooseRoute`, used
by `llm_router`) and `WorkflowResolver` (used by `subflow`), plus the shipped
`Adapters\{PrismLlmClient, LaravelAiLlmClient}` and the auto-detector. Both
libraries are `suggest`-only + `class_exists()`-guarded — **core's `require`
must stay PHP-only**. `FakeLlmClient` keeps flow tests offline.
- `Registry\KindId` — the id convention. Canonical ids are
`@particle-academy/<name>`; old spellings live on as `aliases`. **Anything
keyed by kind name must key on EVERY id a kind answers to** — registry lookups
and executor bindings both do, and a rename like `llm_branch` → `llm_router`
is only survivable because of it.
- `Schema\*` — WorkflowSchema v1 shapes; `Attributes\#[FlowNode]` — discovery.
- `Marketplace\*` — third-party node packages (the `list`/`search`/`get` side of
what `npx fancy-cli add node <kind>` installs).
## The optional Laravel layer (`src/Laravel/`)
`illuminate/support` is **`suggest`-only** — the core above runs framework-free.
When Laravel *is* present, `FancyFlowServiceProvider` wires: the `FancyFlow`
facade + `FancyFlowManager`, the queue drivers (below), `TriggerCohort` (ordering
+ guarding the runs one event fires — see `dispatchCohort()`), `EloquentWorkflowResolver`
+ `ContainerWorkflowResolver`, `ContainerLlmClient`, `#[FlowNode]` discovery,
Artisan commands, HTTP controllers, and `RunEvent` → Laravel events. Nothing here
may leak into the core's `require`.
**Discovery must never walk the process's classes.** `FlowNodeDiscovery::scan()`
runs on every app boot, which in a host's test suite means once per test, and a
process never un-declares a class. Iterating `get_declared_classes()` made each
boot cost more than the last until a 3,300-test suite could not finish (#15). It
reads the declarations out of the files it walks instead, and
`FlowNodeDiscoveryCostTest` fails if its cost starts following the process again.
### Two queue drivers (`fancy-flow.queue.driver`)
Durable runs are carried by one of two drivers. **Every entry point routes
through `RunWorkflowJob::enqueue()`** — keep it that way, or switching drivers
stops being a config change.
- `single` — `Jobs\RunWorkflowJob`. One job for the whole graph; the
`node_outputs` checkpoint is written once, when it returns.
- `per_node` (default since 0.11) — `Jobs\AdvanceWorkflowJob` (compute the ready
frontier, dispatch what `Runs\DispatchLimit` allows, settle the run) +
`Jobs\RunNodeJob` (claim one node, run it, checkpoint it). Supported by
`Runs\{NodeClaims, Frontier, DispatchLimit, GraphReplay, NodeRetryPolicy,
RunSetup}` and the `workflow_run_nodes` table.
**`per_node` dispatches ONE node of a run at a time by default (0.54.0,
fancy-flow-php#17, the owner's ruling).** `DispatchLimit::select` takes the first
`limit - held` ready nodes in declaration order, where held is CLAIMED + PAUSED.
Parallel is `max_concurrent` N, `0` or `"unlimited"`. Unset means serial on
purpose, including a published config whose `env()` returns null — do not "fix"
that back to unlimited. The rule is shared with the TS and Python coordinators
through `flow/durable-dispatch`; change it there first.
Three rules hold this together, and each exists because the alternative fails
silently:
1. **The engine is not reimplemented.** `RunNodeJob` executes a node by replaying
the graph *through* `FlowRunner` with completed nodes fed back as
`resumeOutputs` and every other node fenced off by `bindNode`. Inputs,
branching, and skips are therefore the engine's, not a driver's copy of them.
2. **Activated ports come from the engine's own `node-output` events**, stored on
the claim row. `Frontier` reads them; it must never compute them. A second
copy of `activatedPorts` would agree for a year and then disagree on one
branch.
3. **The claim is a unique constraint, not a check.** `(run_key, node_id)` +
`insertOrIgnore`. A lost race is a NO-OP. The `owner` token is what lets a
job's own retry re-enter its claim instead of deadlocking against it — and it
is also what makes the retry's idempotency key identical to the first
attempt's, since both read the same row.
A fourth rule now sits beside them, about time rather than concurrency:
4. **The retry clock is `created_at`, never `claimed_at`.**
`WorkflowRunNode::firstAttemptAt()` is what an idempotency window is measured
from, and `claimed_at` is refreshed on every reclaim — so reading it would
report a retry 25 hours late as seconds old and reuse a key the provider had
already forgotten. `RunIdentityPerNodeTest` travels the clock thirteen hours
between attempts precisely because three attempts inside one second pass
whichever column is read.
**A human gate holds no worker.** `RunNodeJob` reaching a gate records the
pause, dispatches `Events\HumanInputRequested` — the request itself, carrying
the node and the kind's detail — and RETURNS. Nothing on the server waits for a
person. `WorkflowRun::approve()` / `submitInput()` is what enqueues the
continuation, so the inbound answer starts the next job.
## Conventions
- **PHP 8.4 floor.** Immutable value objects, named-arg constructors, `match`.
- **No new runtime dependencies** in the core. Injectable clients over hard deps.
- **Faithfulness first.** If in doubt, match `run-flow.ts` semantics and add a
parity fixture. Where the PHP twin intentionally differs from the TS *code*
(never the TS *contract*), say so in a comment (see `collectInputs`).
- **Regenerate fixtures deliberately.** They're golden files; only regenerate
when behavior legitimately changes, and eyeball the diff.
## Commands
```bash
composer install
composer test # pest — unit + parity
vendor/bin/pest tests/Unit/EngineTest.php # one file
```
## Publishing
PHP package — auto-syncs to Packagist from git tags (no publish workflow). Ship =
bump version → commit → tag `vX.Y.Z` → push tag → Packagist picks it up. First
publish requires a one-time Packagist submit + GitHub webhook. Then advance the
envelope pin. See the envelope's `.ai/knowledge/publishing.md`.
## Roadmap
**Shipped (current tag `v0.15.0`):** 0.1 core → 0.2 Laravel layer → 0.3 durable +
agentic → 0.4 durable human input (`user_input` pause/resume) → 0.5 capabilities
+ namespaced kind ids (`llm_router`, `subflow`, shipped LLM adapters) → 0.6–0.8
Human+, the node marketplace, and `#[FlowNode]` discovery → 0.9 trigger cohorts
(`dispatchCohort`, `TriggerGuard`, the `skipped` status) → 0.10 one job per node
(the `per_node` queue driver, the `workflow_run_nodes` claim table, per-node
retries keyed on `sideEffects`) → 0.11 that driver became the DEFAULT → 0.12 the
PHP 8.4 floor → 0.13 human gates that cannot be walked past → 0.14 `LiveContract`
+ the shared conformance tables → 0.15 schema-typed `llm_call` output (`response_schema` → a parsed, validated `data`; `StructuredOutput`). Treat all of it as **done, not planned** —
`src/Laravel/` (service provider, facade, both queue drivers, `TriggerCohort`,
Eloquent + container resolvers, Artisan, HTTP), `src/Marketplace/`,
`src/Schema/`, and `src/Attributes/` are all live.
**This line said `v0.11.0` while the package was on `v0.14.1`** — three minors of
"planned" work that had in fact shipped. A roadmap in a file nobody has to touch
when they release is a roadmap that goes stale silently, so prefer describing
what EXISTS in the sections above; a version number here earns its place only by
being checked against `git tag` when it is read.
**`per_node` is the default as of 0.11.** `single` is still fully supported and
NOT deprecated — `FANCY_FLOW_QUEUE_DRIVER=single` selects it — so the two drivers
have to keep passing the same durable suite. Each pins its own driver explicitly
in its test case (`DurableTestCase` → `single`, `PerNodeTestCase` → `per_node`);
neither may go back to inheriting the shipped default, or the next flip silently
repoints a suite at the driver it does not name.
**Human gates fail CLOSED as of 0.13.** `user_input` and `human_approval` decided
whether to pause by reading their own input port, so a pre-filled `values` /
`approved` value ran the flow straight past the person it was waiting for.
Restoring that is now explicit and per-node (`autoAnswerFromInput`), and
recording an answer for a node the run is not parked on throws instead of
queueing a write nobody reads.
**Parity is a test result, not a claim.** `tests/Parity/` runs the golden
workflow fixtures plus `shared/expr`; `tests/Unit/MarketplaceTest.php` runs
`shared/satisfies-range`. Both tables come from the installed
`particle-academy/fancy-conformance`, and `@particle-academy/fancy-flow` asserts
the same rows on its own side — so a `{{ }}` or range divergence is a red build
in whichever runtime drifted.
Load tables through `Conformance::runTable()`, never by transcribing rows.
`satisfiesRange` was asserted against a hand-copied 17-row duplicate of the
shared table until 0.14.2: two copies agree right up until someone adds a row to
one of them, and nothing anywhere reports that.
Plan: envelope `.ai/plans/fancy-flow-php.md`.
PHP runtime for fancy-flow workflow graphs. The framework-free twin of
@particle-academy/fancy-flow's TypeScript engine. CLAUDE.md symlinks here.
A faithful port, not a redesign. The source of truth for behavior is
@particle-academy/fancy-flow (in the envelope at repos/fancy-flow): its
src/runtime/run-flow.ts, src/schema/workflow-schema.ts, src/registry/*.
When those change, this mirror tracks them kind-for-kind via the parity fixtures.
The guarantee: same WorkflowSchema JSON in, same RunResult.outputs out on
Node and PHP. Don't break it.
Workflow — import/export/validate WorkflowSchema v1. lenient softens
vocabulary (an unknown kind), never the schema version: a document that is
not version: 1 after migration is refused in every mode with an empty graph,
exactly as the TS and Python importers refuse it. Anything that imports in
order to RUN must check ImportResult::refused() — toGraph() and the
subgraph executor throw UnreadableWorkflow, EloquentWorkflowResolver
returns a failure — or a refused document runs as an empty graph and reports
success. Refuse on refused(), not on ! ok: a graph that was read but
carries a connectivity error has always run, and parity fixture 03 pins it.Engine\FlowRunner — the runFlow port (Kahn topo, ports, branching, cycles,
timeout). Note: a node runs when ≥1 incoming edge is active (merge-after-
decision, #1), and collectInputs reads only active edges — that's the
documented contract; don't regress either.NodeKindRegistry + Registry\{NodeKind, ConfigField, Builtin} — the kinds.ExecutorRegistry + Contracts\{NodeExecutor, Resolver} — behavior; resolves
node id → kind → *.Contracts\TriggerGuard — the precondition a cohort run re-checks just before
it starts. Fails CLOSED by design; see Laravel\TriggerCohort.Runtime\{ExecutionContext, RunEvent, RunOptions, RunResult, Port, RunIdentity, ...}.
RunIdentity is what a WRITING node keys an idempotent call on.
$ctx->run?->stepKey($ctx->node->id) is the same on every retry of one step
and different for every other execution of the same node — which is why
attempt is carried on it and is NOT part of the key. (run, node) alone is
insufficient: a node runs once per subflow invocation and once per loop
iteration, so the key composes the invocation path and an optional
occurrence. isReplaySafe() says whether a retry is still inside the
provider's dedup window; past it the caller must REFUSE, because resending
the key and minting a fresh one both write twice. Pinned by
shared/flow-run-identity; the other two runtimes implement the same table.Nodes\<Domain>\*Executor — the 26 default executors, grouped by domain.Nodes\Support\* — injectable client interfaces + deterministic fakes + the
Expr {{ path }} resolver (safe, no arbitrary eval).Capabilities\* — the HOST capability seam: LlmClient (chooseRoute, used
by llm_router) and WorkflowResolver (used by subflow), plus the shipped
Adapters\{PrismLlmClient, LaravelAiLlmClient} and the auto-detector. Both
libraries are suggest-only + class_exists()-guarded — core's require
must stay PHP-only. FakeLlmClient keeps flow tests offline.Registry\KindId — the id convention. Canonical ids are
@particle-academy/<name>; old spellings live on as aliases. Anything
keyed by kind name must key on EVERY id a kind answers to — registry lookups
and executor bindings both do, and a rename like llm_branch → llm_router
is only survivable because of it.Schema\* — WorkflowSchema v1 shapes; Attributes\#[FlowNode] — discovery.Marketplace\* — third-party node packages (the list/search/get side of
what npx fancy-cli add node <kind> installs).src/Laravel/)illuminate/support is suggest-only — the core above runs framework-free.
When Laravel is present, FancyFlowServiceProvider wires: the FancyFlow
facade + FancyFlowManager, the queue drivers (below), TriggerCohort (ordering
dispatchCohort()), EloquentWorkflowResolverContainerWorkflowResolver, ContainerLlmClient, #[FlowNode] discovery,
Artisan commands, HTTP controllers, and RunEvent → Laravel events. Nothing here
may leak into the core's require.Discovery must never walk the process's classes. FlowNodeDiscovery::scan()
runs on every app boot, which in a host's test suite means once per test, and a
process never un-declares a class. Iterating get_declared_classes() made each
boot cost more than the last until a 3,300-test suite could not finish (#15). It
reads the declarations out of the files it walks instead, and
FlowNodeDiscoveryCostTest fails if its cost starts following the process again.
fancy-flow.queue.driver)Durable runs are carried by one of two drivers. Every entry point routes
through RunWorkflowJob::enqueue() — keep it that way, or switching drivers
stops being a config change.
single — Jobs\RunWorkflowJob. One job for the whole graph; the
node_outputs checkpoint is written once, when it returns.per_node (default since 0.11) — Jobs\AdvanceWorkflowJob (compute the ready
frontier, dispatch what Runs\DispatchLimit allows, settle the run) +
Jobs\RunNodeJob (claim one node, run it, checkpoint it). Supported by
Runs\{NodeClaims, Frontier, DispatchLimit, GraphReplay, NodeRetryPolicy, RunSetup} and the workflow_run_nodes table.per_node dispatches ONE node of a run at a time by default (0.54.0,
fancy-flow-php#17, the owner's ruling). DispatchLimit::select takes the first
limit - held ready nodes in declaration order, where held is CLAIMED + PAUSED.
Parallel is max_concurrent N, 0 or "unlimited". Unset means serial on
purpose, including a published config whose env() returns null — do not "fix"
that back to unlimited. The rule is shared with the TS and Python coordinators
through flow/durable-dispatch; change it there first.
Three rules hold this together, and each exists because the alternative fails silently:
RunNodeJob executes a node by replaying
the graph through FlowRunner with completed nodes fed back as
resumeOutputs and every other node fenced off by bindNode. Inputs,
branching, and skips are therefore the engine's, not a driver's copy of them.node-output events, stored on
the claim row. Frontier reads them; it must never compute them. A second
copy of activatedPorts would agree for a year and then disagree on one
branch.(run_key, node_id) +
insertOrIgnore. A lost race is a NO-OP. The owner token is what lets a
job's own retry re-enter its claim instead of deadlocking against it — and it
is also what makes the retry's idempotency key identical to the first
attempt's, since both read the same row.A fourth rule now sits beside them, about time rather than concurrency:
created_at, never claimed_at.
WorkflowRunNode::firstAttemptAt() is what an idempotency window is measured
from, and claimed_at is refreshed on every reclaim — so reading it would
report a retry 25 hours late as seconds old and reuse a key the provider had
already forgotten. RunIdentityPerNodeTest travels the clock thirteen hours
between attempts precisely because three attempts inside one second pass
whichever column is read.A human gate holds no worker. RunNodeJob reaching a gate records the
pause, dispatches Events\HumanInputRequested — the request itself, carrying
the node and the kind's detail — and RETURNS. Nothing on the server waits for a
person. WorkflowRun::approve() / submitInput() is what enqueues the
continuation, so the inbound answer starts the next job.
match.run-flow.ts semantics and add a
parity fixture. Where the PHP twin intentionally differs from the TS code
(never the TS contract), say so in a comment (see collectInputs).composer install
composer test # pest — unit + parity
vendor/bin/pest tests/Unit/EngineTest.php # one file
PHP package — auto-syncs to Packagist from git tags (no publish workflow). Ship =
bump version → commit → tag vX.Y.Z → push tag → Packagist picks it up. First
publish requires a one-time Packagist submit + GitHub webhook. Then advance the
envelope pin. See the envelope's .ai/knowledge/publishing.md.
Shipped (current tag v0.15.0): 0.1 core → 0.2 Laravel layer → 0.3 durable +
agentic → 0.4 durable human input (user_input pause/resume) → 0.5 capabilities
llm_router, subflow, shipped LLM adapters) → 0.6–0.8
Human+, the node marketplace, and #[FlowNode] discovery → 0.9 trigger cohorts
(dispatchCohort, TriggerGuard, the skipped status) → 0.10 one job per node
(the per_node queue driver, the workflow_run_nodes claim table, per-node
retries keyed on sideEffects) → 0.11 that driver became the DEFAULT → 0.12 the
PHP 8.4 floor → 0.13 human gates that cannot be walked past → 0.14 LiveContractllm_call output (response_schema → a parsed, validated data; StructuredOutput). Treat all of it as done, not planned —
src/Laravel/ (service provider, facade, both queue drivers, TriggerCohort,
Eloquent + container resolvers, Artisan, HTTP), src/Marketplace/,
src/Schema/, and src/Attributes/ are all live.This line said v0.11.0 while the package was on v0.14.1 — three minors of
"planned" work that had in fact shipped. A roadmap in a file nobody has to touch
when they release is a roadmap that goes stale silently, so prefer describing
what EXISTS in the sections above; a version number here earns its place only by
being checked against git tag when it is read.
per_node is the default as of 0.11. single is still fully supported and
NOT deprecated — FANCY_FLOW_QUEUE_DRIVER=single selects it — so the two drivers
have to keep passing the same durable suite. Each pins its own driver explicitly
in its test case (DurableTestCase → single, PerNodeTestCase → per_node);
neither may go back to inheriting the shipped default, or the next flip silently
repoints a suite at the driver it does not name.
Human gates fail CLOSED as of 0.13. user_input and human_approval decided
whether to pause by reading their own input port, so a pre-filled values /
approved value ran the flow straight past the person it was waiting for.
Restoring that is now explicit and per-node (autoAnswerFromInput), and
recording an answer for a node the run is not parked on throws instead of
queueing a write nobody reads.
Parity is a test result, not a claim. tests/Parity/ runs the golden
workflow fixtures plus shared/expr; tests/Unit/MarketplaceTest.php runs
shared/satisfies-range. Both tables come from the installed
particle-academy/fancy-conformance, and @particle-academy/fancy-flow asserts
the same rows on its own side — so a {{ }} or range divergence is a red build
in whichever runtime drifted.
Load tables through Conformance::runTable(), never by transcribing rows.
satisfiesRange was asserted against a hand-copied 17-row duplicate of the
shared table until 0.14.2: two copies agree right up until someone adds a row to
one of them, and nothing anywhere reports that.
Plan: envelope .ai/plans/fancy-flow-php.md.
How can I help you explore Laravel packages today?