⚡ PHP Speed Tooling: Partial Function Application, Tokens and Flow
on September 13, 2026
PHP 8.6 is not a stable production release as I write this. The experiments ran on 8.6.0beta2, isolated from the host PHP 8.5.4 CLI, which cannot even parse foo(?). Treat every claim as pre-release behavior that I actually ran, not as a promise about November.
The first question I asked was still this one:
What happens to Flow when PHP itself becomes better at expressing functional pipelines?
It is now the first experiment inside a larger one:
How much tooling can modern PHP provide before we need another abstraction?
Three independent runs arrived at the same rule.
Need argument binding? → PFA
Need lightweight source structure? → PHP tokens
Need to iterate local files? → foreach
Need actual orchestration? → now consider Flow
Use the PHP primitive first. Add abstraction and orchestration only when they earn their place.
1. How little tooling do we actually need?
Darkwood Flow was a good place to hide a language-feature article, and a worse place to stop. Once Partial Function Application produced a Closure that Flow already accepted, the interesting leftover was not “what else shipped in 8.6?” It was whether the same habit — reach for a helper, a package, a framework — showed up one layer down, in the tools we write about PHP.
This piece is not a claim that PHP is faster than Rust or Go. It is not a claim that tokens beat ASTs. It is three measurements of a cheaper question: did the next abstraction earn its place?
2. PHP 8.6 removes the closure adapter
The tax is not “you wrote a function.” The tax is the extra function you write only so another function can see one leftover argument.
fn ($value) => transform($value, $configuration)
PHP 8.6 Partial Function Application is the native primitive:
transform(?, $configuration)
Official Flow examples/flow.php does not have that tax. Those jobs are real bodies. The adapters live in consumer code. nolife-language’s LanguagePipelineFactory closes over a corpus. Once that body is a named function — and only then — the remaining closure is an adapter:
static fn (BenchmarkState $value): BenchmarkState => loadPassages($value, $corpus);
flow-pipe’s budget step is the smaller version: fn ($value) => applyBudget($value, $budget).
Not every wrapper is this tax. flow-pipe also writes fn ($ctx) => $step->apply($ctx). That method is already unary. PHP 8.1 first-class callables already delete it: $step->apply(...).
PFA starts where FCC stops: one or more arguments are already known. A call that contains ? or ... does not run. It returns a Closure.
$load = loadPassages(?, $corpus);
$trim = applyBudget(?, 8);
Reflection on 8.6.0beta2:
load PFA: Closure static (BenchmarkState $state): BenchmarkState
trim PFA: Closure static (string $text): string
Those are the same shapes as the arrow adapters, without redeclaring the types. foo(...) is PHP 8.1 first-class callable syntax, now the degenerate case of the same feature.
I will not call this a revolution. It is the adapter you were already writing, with the types left on the original function. Arrow versus PFA on 8.6.0beta2 differed by a few nanoseconds. Flow’s driver loop still dominates that microbench. PFA is a readability story, not a performance story.
3. ? and ... are not the same thing
This is the part that is easy to get wrong, including by reading the v2 RFC body and stopping. An amendment shipped with 8.6: every ? is required in the resulting closure, even if the original parameter had a default. ... still inherits optionality.
$question = exampleOptional(?, ?);
// required arity: 2. $c always gets its default.
$ellipsis = exampleOptional('foo', ...);
// required arity: 0. $ellipsis() uses both defaults.
Named placeholders reorder the closure, not the underlying call. Two restrictions change how you write pipelines.
Bound arguments run at creation time, not at invocation. An arrow function delays the inner call; PFA does not.
new cannot be partialled. new stdClass(?) dies with Cannot create Closure for new expression. Static factories are fine.
The callback footgun is the one the RFC already wrote down:
intval(?)('10', 2); // 10 — extra argument ignored
intval(...)('10', 2); // 2 — 2 became $base
array_find() and friends pass a key as a second argument. Prefer intval(?) unless you intend to forward the rest.
4. Put PFA into Flow
I did not patch Flow. I passed the partials in.
Before, the leftover tax was still an arrow:
$flow = new Flow(
static fn (BenchmarkState $state): BenchmarkState => loadPassages($state, $corpus),
driver: new FiberDriver(),
);
$flow->fn(
static fn (BenchmarkState $state): BenchmarkState => applyBudgetToState($state, 12),
);
After:
$flow = new Flow(loadPassages(?, $corpus), driver: new FiberDriver());
$flow
->fn(applyBudgetToState(?, 12))
->fn(collect(?, $box));
$flow(new Ip(new BenchmarkState()));
$flow->await();
That is the code in examples/C-flow-pfa.php. The result on 8.6.0beta2 was the loaded, budgeted BenchmarkState. collect(?, $box) is the FlowCollector pattern extracted to a function. PFA did not invent a collector; it removed the adapter around one.
Nothing happened to Flow. That is the interesting result. PFA already returns the Closure Flow’s fn() expects. No partial() helper. No curry API. No reason to raise Flow’s php: >=8.5 floor. Widening Closure to a generic callable is still a bad idea: PHP callable arrays collide with Flow’s existing array/config semantics.
PHP owns syntax and argument binding. Flow owns execution.
5. Pipe is not partial application
The pipe operator shipped in 8.5. Its right-hand side must be a single-parameter callable. That is why 8.5 pipelines filled up with parenthesized arrows. PFA is the missing half of that sentence, not a replacement for the sentence.
Same input, two runtimes. Native PHP:
$input
|> removeNoise(...)
|> normalizeWhitespace(...)
|> applyBudget(?, 14);
Flow: the same three callables plus collect(?, $box), FiberDriver, await(). Both produced 'hello world'.
PFA → bind arguments
|> → thread a value
Flow → execute jobs
They overlap on composition of unary functions. They do not overlap on multi-IP scheduling, drivers, error jobs, or events. Flow’s fn() is composition, not a pipe operator. Pipe usage lives in consumers. If you do not need that loop, you do not need Flow for the chain.
6. The primitive-first rule
We avoided adding partial() to Flow because PHP already solves the problem.
That is a broader engineering rule than a language-feature article usually admits:
Prefer the language primitive before inventing the framework abstraction.
What other tooling abstractions can we avoid?
7. A PHP Speed Tooling experiment
The useful sentence is not “PHP is faster than another language.” It is this:
Choose the cheapest representation that still contains the information your tool needs.
Finding arrow functions that are already PFA or FCC is a lexical problem. PHP already exposes a tokenizer. An AST would give us scope and types we do not need in order to say “this fn is a single call with one leftover argument.” Whether tokens are faster than an AST is an expectation. We did not measure a twin.
8. tools/pfa-opportunities
The conservative PFA Opportunity Scanner looks for those arrow functions.
Darkwood keeps small, repository-local engineering tools under tools/. That is a convention of this experiment, not a claim about PHP at large. A small engineering problem does not automatically require a package, a framework, or a separate application. Sometimes the right solution lives beside the code it analyzes.
tools/
└── pfa-opportunities/
├── bin/scan.php
├── src/functions.php
└── fixtures/
PHP files
↓
PhpToken::tokenize()
↓
remove ignorable tokens
↓
find simple arrow functions
↓
classify callable shape
↓
PFA / FCC / ignore
The tool uses PhpToken::tokenize() because isIgnorable() is native and every token already knows its line. No php-parser. No Symfony Finder. RecursiveDirectoryIterator is enough. Report only. About 540 lines. False negatives are fine. A false-positive PFA suggestion is not.
The high-confidence shapes:
fn ($x) => foo($x, $bound); // PFA → foo(?, $bound)
fn ($x) => $step->apply($x); // FCC → $step->apply(...)
The tool’s own honest PFA site is a two-argument function. The plain runner is a foreach:
$config = new ScanConfig();
foreach ($files as $file) {
foreach (scanFile($file, $config) as $finding) {
echo formatFinding($finding, displayPath($finding->file, $roots)), "\n";
}
}
PFA binds the config once: $scan = scanFile(?, $config);.
9. The scanner finds almost no PFA
Against nolife-language, flow-pipe, and Flow src/:
144 files
498,107 bytes
51,663 significant tokens
PFA candidates 0
FCC candidates 3
Flow src 0
Zero is a result, not a failure. We built a tool to test a hypothesis, and the tool said the selected production trees do not currently contain obvious PFA migration sites. Official Flow jobs are real bodies. LanguagePipelineFactory’s use ($corpus) closure is a real body too.
The three FCC hits confirm the first study:
TokenPipelineFlowRunner:$first->apply(...),$step->apply(...)CompressChunkStep:$this->applyToChunk(...)
The two PFA hits appear only when you scan the playground file that extracted loadPassages and applyBudget on purpose.
10. Tokens are enough — until they aren't
Tokens give us syntax. They do not magically give us semantic understanding.
The scanner refuses shapes it cannot prove safe. fn ($p) => $p->toArray() looks like FCC until you notice the receiver is the parameter. There is no $p at the array_map site to write $p->toArray(...) on. Blindly suggesting PFA or FCC there would be a lie.
Also silence:
- property predicates (
fn ($result) => $result->regression) - boolean expressions (
fn ($value) => complicated($value) && other($value)) - nested calls (
fn ($x) => foo(bar($x), $c)) $xused twice, or not first- multi-statement
functionbodies - by-ref, variadic leftovers,
new, identityfn ($x) => $x - anything that needs types or name resolution to be sure
If the problem grows into reliable automated rewriting, an AST and type-aware tooling may become the correct abstraction. Speed Tooling means avoiding unnecessary machinery, not refusing machinery when it becomes necessary.
11. Should the tool use Flow?
The same scanFile() can be a Flow job, because that is what PFA is for:
$flow = (new Flow(scanFile(?, $config), driver: new FiberDriver()))
->fn(appendScanFindings(?, $bag));
foreach ($files as $file) {
$flow(new Ip($file));
}
$flow->await();
Both runners found the same three FCC sites. Eight iterations on 8.6.0beta2:
Plain PHP ~79.5 ms
Flow ~120.2 ms
Peak memory ~6 MiB both
I am not going to say “Flow is slow.” For a small local CPU-bound directory scan, Flow adds orchestration work without solving an orchestration problem. foreach wins here. Not yet.
12. When Flow earns its place
PHP primitives already handle argument binding, callables, pipeline syntax, tokenization, and simple iteration.
Flow becomes useful when execution is the problem:
multiple IPs
driver-controlled execution
error jobs
events
IP strategies
await()
overlapping work where the selected driver supports it
Those are capabilities verified in the Flow source. The library has no built-in branch/join operator, no retry product, and no tracing product beyond Symfony events. I am not going to preview any of those as if they had shipped.
Until the scan is no longer “walk these files,” foreach is the primitive.
13. PHP primitive first
The fastest tool is not necessarily the one written in another language or backed by the most sophisticated parser. Sometimes it is the tool that does less.
PFA instead of an adapter abstraction.
PhpToken instead of a parser stack.
foreach instead of an orchestration engine.
And when foreach stops being enough, that is where Flow becomes interesting.
PHP 8.5 gave us the pipe. PHP 8.6 gives us partial application. PHP has also had a tokenizer sitting there for years. The interesting question is no longer how much syntax a library can provide, but how little it needs to provide before the next primitive actually fails.
PHP primitive first. Add tooling abstraction only when the problem requires it. Add Flow when execution becomes orchestration.
Demo repository
The playground is the Symfony study repo flow-partial-function-application. Scripts live in examples/. The PFA Opportunity Scanner lives in tools/pfa-opportunities/. Flow is a path dependency and was not modified.
docker compose run --rm php86 php examples/run-all.php
docker compose run --rm php86 php tools/pfa-opportunities/bin/scan.php \
/work/content/nolife-language/src \
/work/content/flow-pipe/src \
/work/darkwood/src/Darkwood/Component/Flow
PHP 8.6.0beta2 (php:8.6-rc-cli). Flow 8.1.x. Host PHP 8.5 cannot parse the PFA examples.
Sources
- PHP 8.6 : https://www.php.net/archive/2026.php#2026-09-10-1
- Code source : https://github.com/matyo91/flow-partial-function-application
- Slides : https://github.com/matyo91/slidewire