Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message
Agents
particle-academy/fancy-flow-php
claude-code
cursor
flow
workflow
automation
ai
DAG
orchestration
agentic
particle-academy
fancy-flow

PHP runtime for fancy-flow workflow graphs. The framework-free twin of @particle-academy/fancy-flow's TypeScript engine. CLAUDE.md symlinks here.

Install
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`.

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:

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

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource