Download the PHP package particle-academy/fancy-flow-php without Composer

On this page you can find all versions of the php package particle-academy/fancy-flow-php. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package fancy-flow-php

fancy-flow-php

A PHP runtime for @particle-academy/fancy-flow workflow graphs. The framework-free twin of fancy-flow's headless TypeScript engine: the same WorkflowSchema JSON in, the same outputs out. A graph an agent or human authors in <FlowEditor> runs unchanged on a PHP backend.

The editor stays the one authoring surface; PHP becomes a peer runtime alongside Node — the workflow sibling of holy-sheet / dark-slide / last-word.

Requires PHP 8.4+. The core has zero framework dependencies. (A Laravel integration — service provider, queued durable runs, #[FlowNode] discovery, broadcast run status — ships as an optional layer; see Laravel.)


Quick start

The contract it mirrors

Ported faithfully from [email protected] — this is a port, not a redesign:

The built-in library — 24 kinds across 7 domains

Builtin::register() installs the full library (Builtin::executors() binds a default executor for each):

Domain Kinds
trigger manual_trigger, webhook_trigger, schedule_trigger
human user_input, human_approval, notify
logic branch, switch_case, for_each, merge, wait, transform, subflow
data memory_store, data_store, variable
ai llm_call, llm_router, tool_use, embed_search
io api_request, webhook_out
output output, log

Plus the structural note (never executed) and subgraph (runs a nested flow), available via Builtin::register($registry, withStructural: true).

for_each derives iteration from the graph. When its item port has a downstream lane, that lane runs sequentially once per resolved item and done publishes {items, results, failures, count} after the last one. Each result is the map of node outputs from that item; a failed item leaves null at its index and is described in failures, while later items continue. Iteration defaults to at most 1,000 items and maxItems may set a lower cap or raise it as far as 10,000. A human wait inside the lane is addressed by occurrence and resumes without replaying completed body nodes. With no item edge it keeps the data-only behavior; set mode: collect to opt out explicitly while leaving an item edge wired.

If any item fails, downstream nodes still receive the aggregate so they can render or persist the complete batch report, but the run settles as partial (RunResult::PARTIAL, ok === false) rather than success. Durable runs keep their outputs and do not retry already-successful items; hosts should handle WorkflowRun::PARTIAL as a distinct terminal state.

Kind ids are namespaced

A kind's name is its canonical id and is what gets written into saved documents — so a bare name two packages could both claim is unfixable after the fact. Built-ins are published as @particle-academy/<name>, with every previous spelling kept as an alias:

Lookups resolve aliases in both directions, and executors bind under every id a kind answers to — a graph saved with the bare name keeps running, and a host that bound its executor under the bare name keeps winning. Publish your own kinds namespaced and list old names in aliases:

On the TS side these kinds ship without executors — each host wires where memory, HTTP, and AI actually go. The PHP twin ships default executors so a flow runs out of the box, while every one stays overridable. The external kinds take injectable clients so they work framework-free and deterministically:

Omit ExecutorDeps and you get deterministic echo/in-memory fakes — ideal for tests and local runs. (The 0.2 Laravel layer binds these to the HTTP client, laravel/ai, cache/Eloquent, and Notifications.)

Capabilities — llm_router and subflow

Two built-ins need something core must never depend on: a model, and somewhere workflows live. A node that imported a provider SDK would force every consumer to install it, so core declares the contract and the host supplies the implementation.

llm_router — a shuttle, not an engine

It carries the declared routes out to an LLM client and carries the choice back. No provider SDK, no prompt engineering, no response parsing, no retry policy — all of that belongs to the client. What it does own is graph integrity: a port the model invents never routes. An unrecognised choice goes to the fallback port (or the first declared route when that switch is off) and always logs a warning — because emitting on a port with no edge silently ends the branch and the run then reports success having done nothing.

It ships working. Adapters for Prism and laravel/ai are included and auto-detected — install either one and the node just works:

Which Prism? particle-academy/prism is Particle Academy's maintained fork of prism-php/prism, which has not shipped since March 2026. It carries the same Prism\Prism\ namespace, so the adapter is identical either way and switching needs no code change — the fork is simply the one still getting releases.

Do not install both. They provide the same namespace and the fork declares no replace, so Composer will happily install the pair and leave you with two copies of every Prism\Prism\* class. If you already require prism-php/prism, remove it in the same commit you add the fork.

Both are suggest-only and class_exists()-guarded; this package's require stays PHP-only. Both constrain the model to the declared ports via structured output (Prism's EnumSchema, laravel/ai's JSON-schema enum) rather than parsing a port name out of prose. Install both and fancy-flow will not choose for you — set fancy-flow.llm.driver to prism or laravel-ai. Install neither and the node aborts naming exactly what to install or register; it never guesses.

Hand-rolled stays first-class — registering your own replaces the auto-detected one:

The chosen route travels with the value — {route, reason, input} down the chosen port — so a completed run explains itself without replaying the model call. Testing a flow needs no API key and no network: FakeLlmClient::always('billing').

The kind was renamed llm_branch → llm_router (it picks one of N named routes; it is not a two-way branch). Config keys are unchanged, and llm_branch / @fancy/llm_branch remain aliases, so existing graphs keep running.

subflow — run another workflow

Runs a child graph through this same engine, so all it needs from the host is where workflows live. Three modes: output (result on out), stream (live progress), both. Child progress is surfaced on the parent's feed as tagged log lines attributed to the subflow node — a child's node ids mean nothing in the parent graph, so its events are never re-emitted raw. A depth guard (default 8) names the offending reference instead of overflowing the stack.

Under Laravel with persistence enabled, subflow references resolve against the stored workflows table out of the box (by name, highest version, or numeric id). Point it elsewhere by binding your own:

Capabilities::status() answers "what does this graph need that I haven't wired?" before a run fails halfway through.

Custom nodes

A node kind has two halves, kept in sync:

An executor is a FancyFlow\Contracts\NodeExecutor (execute(ExecutionContext): mixed), a callable, or a class-string of either. Class-strings resolve through a Resolver (new by default; the container under Laravel) so executors get full constructor DI. Return Port::branch(...) / Port::only(...) to route; emit events with $ctx->emit(...); stop the run with $ctx->abort(...).

Laravel

Installing under Laravel auto-registers the service provider + FancyFlow facade. Executors resolve through the container (constructor DI), api_request uses Laravel's HTTP client, memory_store / data_store use the cache, and each run's events re-emit as Laravel events.

Artisan: flow:run {file} --input=…, flow:list-kinds, flow:validate {file}, flow:discover. Publish config with --tag=fancy-flow-config.

Durable, queued runs

Enable persistence + publish the migrations, and dispatch a run onto a queue. Each attempt checkpoints the completed nodes, so a retry resumes from the last completed node rather than restarting:

A human_approval node pauses the run (status awaiting_approval) instead of failing — the trust-but-verify staged write. Resume with a recorded decision:

A user_input node pauses the same way (status awaiting_input) instead of passing empty values through — the mid-run human form. Render the paused node's form, then resume with a typed values payload:

awaitingForm() merges the node's own config over the kind's configSchema-declared defaults, so a host UI can render the form without knowing the kind. Submissions are persisted per node in the submissions column (nodeId => values), beside the bool-only approvals.

Expose a flow as a webhook, and give any model its own flows:

One job per node

A run is carried on the queue by one of two drivers, selected with fancy-flow.queue.driver:

single per_node (default)
Jobs per run 1 1 per node, plus a bookkeeping job between them
Checkpoint written once, when the graph returns as each node finishes
Worker killed mid-run re-runs every node completed since the last checkpoint loses at most the node that was in flight
Independent branches sequential, in one process one node at a time by default; in parallel on separate workers when you ask
tries one setting for the whole graph per node

per_node has been the default since 0.11. single shipped through 0.10 and is still supported (FANCY_FLOW_QUEUE_DRIVER=single). The reason per_node won is that a checkpoint written after the whole graph returns is not written at all when the worker is killed — a timeout, a deploy, an OOM — and the retry then re-runs everything that had completed, including nodes that must not run twice.

One node at a time, unless you ask

Under per_node, a run hands the queue one node at a time: a node's successor is dispatched only once that node has settled, in the graph's own declaration order among whatever is ready. A node paused for a person keeps its place, so nothing queues alongside a human gate. Several llm_call nodes becoming ready together no longer fire at one provider at once, and "what ran, in what order" is the same answer on every run of the same graph.

Parallel branches are something a host asks for, per host or per run:

Unset is serial, including in a config file published before 0.54.0 that still reads env('FANCY_FLOW_MAX_CONCURRENT'). A negative or non-numeric value is refused by name. FancyFlow\Laravel\Runs\DispatchLimit holds the rule, and the shared flow/durable-dispatch conformance table pins it on the TypeScript and Python coordinators too.

It needs the workflow_run_nodes migration. (run_key, node_id) is unique there, and claiming a node is an insert against that constraint — so two workers computing the same node as ready is settled by the database, and the loser does nothing rather than executing a second time.

Retries become a per-node question, which is where they belonged. A kind declaring sideEffects: 'unsafe-to-replay' gets exactly one attempt no matter what tries says; anything flaky can be given several without putting its neighbours at risk:

A queue round trip per node is real overhead, and for a chain of fast nodes it is most of the cost. queue.drain_limit lets one job keep going inline while the next step is unambiguous — a single ready successor, single-attempt, no human wait. Under a parallel limit a fan-out always dispatches, so parallelism is never traded away. It is off by default, because it trades a little of the durability the driver exists to provide.

Per-node state is queryable: $run->nodes() returns the durable execution record for each node with its resolved inputs, output, status, activated ports, attempt count, error, and timestamps. Inputs are captured from the real ExecutionContext before the executor starts — including seeded entry values, source-node aliases, explicit nulls, and $props — rather than inferred from upstream outputs. The durable copy recursively replaces sensitive-key values with [REDACTED] and is bounded to 256 KiB by default; the executor still receives the original, untouched inputs:

On retry, inputs describes the latest attempt represented by the row. Skipped nodes and checkpoints created before this column existed leave it null; the driver never invents historical inputs it cannot know.

Change the storage ceiling with fancy-flow.persistence.recorded_input_max_bytes. Redaction is always on and cannot be disabled by workflow-authored data.

Testing a durable run — do not drive a real worker

A real queue worker in a test introduces a race that has nothing to do with the code under test: between enqueueing a job and a worker being able to see it there is a gap, and a worker started with --stop-when-empty can find the queue momentarily empty, exit, and leave your assertion reading running. It gets worse as the surrounding suite gets longer, so it looks like flakiness and behaves like a threshold.

This package's own durable suite never starts one. Two techniques:

sync for ordinary durable tests. Set queue.default = 'sync' and the whole advance → node → advance chain runs inline. No worker, no scheduling, no race. Most tests want this.

A faked queue, drained by hand, for anything about worker BEHAVIOUR. sync runs the chain to completion, which makes "the worker died here" impossible to express. Fake the queue instead and drain it yourself, in the order a worker would — pending node jobs, then the advances they queued — stopping after N:

Stopping IS the kill. The run is left in exactly the state a worker would have left it, which is how the per-node tests assert an abandoned frontier, a half-settled run, or a retry re-entering its own claim. A real worker cannot be made to stop on demand at a chosen node; this can.

QueuePump is shipped in src/, not a snippet to copy: the per-node suite in this package drives itself through the same class you get, so it cannot drift from what is documented here.

One trigger, several workflows

When a single event fires more than one workflow, dispatch them together:

A loop over dispatch() gives you three independent jobs in whatever order the worker picks them up. If $archiveFlow deletes the deal, $notifyFlow runs afterwards against a record that is gone — and completes successfully, having done nothing. Nothing throws. That silent success is what a cohort removes.

A cohort is ordered (cohort_seq), serialized (each run enqueues its successor when it settles — a run parked on a human wait holds the cohort), and guarded: the named guard is re-checked immediately before each run starts, not at dispatch, because the hazard is precisely what changed in between. A run whose guard fails is skipped, with the reason recorded:

record-exists ships built in. For anything else, implement TriggerGuard and register it — guards resolve by name from the container, so constructor DI works and nothing is serialized into a job:

Guards fail closed: one that throws, or resolves to the wrong thing, skips the run. A skip is visible and re-runnable; a run over missing state is neither. Pass policy: WorkflowRun::POLICY_PARALLEL for fan-outs that genuinely share no state.

The TypeScript twin is runCohort() in @particle-academy/fancy-flow — same policies, same guard semantics, in-process rather than across a queue.

Agentic

The agent kind runs an LLM with tools and bounded multi-step reasoning (AgentExecutor), backed by the LlmClient + ToolInvoker contracts — bind laravel/ai (or your own) and register tools. Every step streams via emit() and the full trace is returned, so an agent run is auditable and (when durable) resumable.

Parity

tests/Parity/fixtures/*.json are shared golden files — a WorkflowSchema + initialInputs + expected {ok, outputs} — covering every built-in kind and the tricky engine cases (merge-after-decision, cycles, branch ports, entry inputs, subgraph, unknown-kind). The PHP suite runs each through FlowRunner and asserts the golden result. The same fixtures are the contract a Node harness asserts against fancy-flow's engine, so any divergence between the two runtimes shows up as a failing fixture.

Roadmap

License

MIT © Particle Academy


🤖 Note to agents

If you are an AI agent reading this while building for a user:


All versions of fancy-flow-php with dependencies

PHP Build Version
Package Version
Requires php Version ^8.4
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package particle-academy/fancy-flow-php contains the following files

Loading the files please wait ...