⚖️ Laravel Pipeline and Darkwood Flow: Two Execution Models for Composing PHP Steps
on July 11, 2026
Most PHP developers have used a pipeline. Fewer have asked what kind of pipeline it is.
When we say "pipeline," we might mean Unix pipes, CI/CD stages, middleware stacks, or an ETL chain. The word describes composition: step A, then step B, then step C. It does not, by itself, describe execution: whether those steps run one after another on a single thread, whether they can overlap, whether they process one value or many, or whether a step can stop the chain early.
Laravel's Illuminate\Pipeline and Darkwood's Flow component both answer the composition question. They answer the execution question very differently.
This article is not a Laravel tutorial. It is not a Symfony tutorial. It is not a benchmark, and it is not an advertisement for either library. It is an explanation of two execution models that happen to compose work in a pipeline-shaped way—and a demonstration of why that distinction matters in practice.
The central idea is simple:
A pipeline defines how work is composed. The runtime determines how that work executes.
By the end, you should be able to look at a "pipeline" in any PHP codebase and ask the right question: not "what are the steps?" but "what is the execution model?"
Introduction: an overloaded word
Ask three experienced PHP developers what a pipeline is, and you may get three compatible but incomplete answers.
One will describe middleware: a request passes through authentication, session handling, and routing before it reaches a controller. That is Laravel's dominant mental model.
Another will describe Unix pipes: the stdout of one process becomes the stdin of the next. Data flows left to right; each stage blocks until it has produced output.
A third will describe orchestration: a job queue, a batch importer, or a data-processing chain where many records move through the same logical steps—possibly concurrently.
All three are pipelines in the compositional sense. None of them specifies the same execution semantics.
That gap becomes painful when we compare libraries. Laravel Pipeline is routinely described as "the middleware implementation." Darkwood Flow is described as "an async pipeline." Both labels are true and both are insufficient. Middleware is a use case. Async is a runtime property. The deeper question is structural:
What actually makes a pipeline a pipeline?
At minimum, a pipeline needs:
- Ordered steps — a defined sequence of transformations or interceptors
- A flowing value — something passed from step to step (a request, a command, a data packet)
- A composition mechanism — a way to declare or assemble those steps
Laravel Pipeline adds a fourth ingredient that defines its execution model:
- An explicit
$nextcallback — each step receives the current value and a reference to the remainder of the chain
Darkwood Flow replaces that with a different fourth ingredient:
- A driver-mediated runtime — steps are nodes; values are instruction packets (
Ip); completion is coordinated by an event loop or fiber scheduler
Same word. Different execution contracts.
To make this concrete, we built a small Symfony Console demo that runs the same logical pipeline—fetch, hash, save—through both models, measures elapsed time, and logs execution order. The demo is deliberately small. Its purpose is not to crown a winner. It is to make the execution difference visible.
Laravel Pipeline in about 30 lines
Laravel's pipeline is one of the most elegant pieces of machinery in the framework—and one of the most misunderstood, because the elegance is hidden inside nested closures.
The entire execution mechanism of Illuminate\Pipeline\Pipeline reduces to a fluent builder and one array_reduce call. Here is the heart of then():
public function then(Closure $destination)
{
$pipeline = array_reduce(
array_reverse($this->pipes()),
$this->carry(),
$this->prepareDestination($destination)
);
return $pipeline($this->passable);
}
Read that carefully. Three operations matter:
array_reverse($this->pipes())— the pipe list is reversed before reduction$this->carry()— a reducer that wraps each pipe around the accumulated stack$destination— the innermost layer, typically route dispatch or a command handler
If you declare through([A, B, C]), the constructed stack is A(B(C($destination))). A runs first. This reverses the intuitive "list order" for anyone who expects the first array element to be the innermost layer. It is correct for middleware: the first registered middleware is the outermost gate.
Our demo reproduces this mechanism in a stripped-down class with no container, no exception handling, and no finally callback:
public function then(Closure $destination): mixed
{
$pipeline = array_reduce(
array_reverse($this->pipes),
$this->carry(),
$destination,
);
return $pipeline($this->passable);
}
That is the entire onion. Everything else in Laravel's full Pipeline class—container resolution, via('handle'), withinTransaction(), Macroable, Conditionable—is important for framework integration, but not for the execution model itself.
Why the onion is elegant
The pipeline does not maintain an explicit list of "current step" at runtime. It does not iterate. It folds pipes into a single callable. Each pipe receives:
- the current passable value
- a
$nextclosure representing the rest of the chain
No shared iterator. No central coordinator. No event bus. Just nested functions.
flowchart LR
input["passable"] --> pipeA["Pipe A"]
pipeA --> pipeB["Pipe B"]
pipeB --> pipeC["Pipe C"]
pipeC --> dest["destination"]
For through([A, B, C]), construction works like this:
flowchart TB
subgraph construction ["array_reduce on reversed pipes"]
dest["destination"] --> wrapC["C wraps destination"]
wrapC --> wrapB["B wraps C"]
wrapB --> wrapA["A wraps B"]
end
wrapA --> runtime["single callable: A(B(C(dest)))"]
This design has consequences we will return to: it is synchronous, single-passable by default, and extremely predictable.
The meaning of $next
A Laravel pipe is not simply function ($value) { ... }. The signature is:
function ($passable, $next) { ... }
The $next argument is the remainder of the pipeline, already folded into a closure. Calling $next($passable) delegates to the inner layers. Returning without calling $next short-circuits the chain: the destination and all downstream pipes are skipped.
This is not an implementation detail. It is the contract that makes middleware work.
Before and after
A pipe can run logic before delegation:
function ($request, $next) {
// before: authenticate, mutate headers, log
return $next($request);
}
A pipe can run logic after delegation by capturing the return value:
function ($request, $next) {
$response = $next($request);
// after: add headers, transform response
return $response;
}
A pipe can transform what downstream receives:
function ($request, $next) {
return $next($modifiedRequest);
}
A pipe can terminate early:
function ($request, $next) {
return response('Forbidden', 403);
// $next never called
}
Laravel's own tests confirm short-circuit semantics: if an early pipe returns without calling $next, later pipes and the destination never run.
Why this model fits HTTP
Laravel wires the HTTP kernel like this:
return (new Pipeline($this->app))
->send($request)
->through($this->middleware)
->then($this->dispatchToRouter());
One request. One passable. One synchronous onion. Each middleware layer decides whether the request proceeds. Authentication middleware that fails does not call $next; the router never runs. That is exactly the control flow HTTP needs.
The same primitive powers command buses and queued job middleware. The pipeline is not "a Laravel HTTP feature." It is a synchronous interception primitive reused across subsystems.
Laravel's Bus\Dispatcher sends every command through the same onion before the handler runs:
return $this->pipeline->send($command)->through($this->pipes)->then($callback);
Queued jobs get a per-job middleware stack in CallQueuedHandler:
return (new Pipeline($this->container))->send($command)
->through($command->middleware())
->then(function ($command) { /* dispatch handler */ });
In every case, the shape is identical: one passable, one synchronous chain, one return value. Laravel does not ask Pipeline to overlap five commands. It asks Pipeline to intercept one command on its way to a handler. That design choice is load-bearing.
sequenceDiagram
participant Outer as OuterMiddleware
participant Inner as InnerMiddleware
participant Router as Destination
Outer->>Inner: next(request)
Inner->>Router: next(request)
Router-->>Inner: response
Inner-->>Outer: response
There is one call stack. One thread. One passable at a time. That is not a limitation for request handling. It is the point.
Building the demo
To compare execution models fairly at the code level, we built a Symfony Console project with two implementations of the same logical pipeline:
Fetch content → Hash content → Save result
The repository lives at darkwood/content/laravel-pipeline. It requires PHP 8.5, Symfony 8.1, darkwood/flow, and amphp/amp.
Three commands expose the behavior:
php bin/console demo:pipeline:sync
php bin/console demo:pipeline:flow
php bin/console demo:pipeline:compare
The pipeline steps
Fetch simulates I/O latency, then produces deterministic content: content-for-task-{id}.
Hash computes sha256 of the content. It is effectively instant.
Save writes a JSON file to var/demo/task-{id}.json. Also instant.
Why controlled delays
We needed reproducible evidence of scheduling behavior. Real HTTP endpoints introduce DNS variance, TLS handshakes, and network jitter—noise that obscures the phenomenon we want to explain.
Instead, five tasks use fixed fetch delays:
| Task | Delay |
|---|---|
| 1 | 0.8 s |
| 2 | 0.3 s |
| 3 | 0.6 s |
| 4 | 0.2 s |
| 5 | 0.5 s |
The sum is 2.4 seconds. If tasks run sequentially and each fetch blocks for its full delay, total fetch time should approach 2.4 s. If independent fetches overlap on an event loop, total time should approach 0.8 s—the longest single delay.
We deliberately did not use external HTTP. We also did not use sleep() or usleep() on the Flow side, because blocking the entire PHP process would prove nothing about event-loop scheduling.
The synchronous implementation uses usleep() in its fetch pipe. That is intentional: it models blocking sequential I/O the way a synchronous middleware stack experiences the world.
Two runners, one fixture
The synchronous runner loops over five tasks and invokes a fresh onion for each:
foreach ($this->taskProvider->getTasks() as $task) {
$result = $pipeline
->send($task)
->through([$this->fetchPipe, $this->hashPipe, $this->savePipe])
->thenReturn();
}
This outer loop is important to understand. Laravel's HTTP kernel sends one request through one onion. Our sync demo sends five tasks through five sequential onions. That is not how Laravel handles concurrent HTTP requests—PHP-FPM does that with multiple processes. Our demo isolates the execution model: "what happens if I process a batch sequentially using a pipeline per item?"
The Flow runner submits five instruction packets into one flow:
$flow = new Flow($this->fetchJob, driver: $this->driver);
$flow->fn($this->hashJob)
->fn($this->saveJob)
->fn(function (SavedResult $result) use ($collector): SavedResult {
$collector->add($result);
return $result;
});
foreach ($this->taskProvider->getTasks() as $task) {
$flow(new Ip($task));
}
$flow->await();
Same steps. Same data. Different execution contract.
A common misconception about Fibers
PHP 8.1 brought Fibers. PHP 8.3 and 8.4 brought more async-adjacent infrastructure. Enthusiasm followed. So did confusion.
Fibers do not make blocking code asynchronous.
A Fiber is a cooperatively schedulable continuation. It can suspend and resume. That is powerful. It is not magic. If your "job" calls sleep(), usleep(), a blocking file_get_contents(), or a synchronous HTTP client, you block the underlying OS thread unless something else in your runtime can switch away—and the blocked syscall still holds the thread.
There are three distinct concepts developers conflate:
- Sequential blocking execution — one thing runs; everything else waits
- Cooperative concurrency — multiple logical tasks interleave when they explicitly yield
- Parallel execution — multiple tasks run simultaneously on multiple CPU cores or OS threads
Darkwood Flow's default driver is FiberDriver. Its delay() implementation looks like this:
public function delay(float $seconds): void
{
$date = time();
do {
Fiber::suspend();
} while (time() - $date < $seconds);
}
Two details matter for practitioners:
- It uses
time(), nothrtime(). Sub-second precision is unreliable - It yields cooperatively inside Flow's driver loop, but it is not equivalent to an event-loop timer
For a demo with 300 ms and 800 ms delays, FiberDriver is the wrong tool. We chose AmpDriver, which delegates to Amp\delay():
public function delay(float $seconds): void
{
delay($seconds);
}
Under the hood, Amp's delay suspends the current coroutine and registers a timer on the Revolt event loop:
function delay(float $timeout, ...): void
{
$suspension = EventLoop::getSuspension();
$callbackId = EventLoop::delay($timeout, static fn () => $suspension->resume());
// ...
$suspension->suspend();
}
The current logical task waits. The event loop does not. Other tasks whose timers or I/O events are ready may proceed.
This is asynchronous scheduling on a single OS thread. It is not parallelism. It is not "real async HTTP" unless your HTTP client participates in the same event loop. It is legitimate, precise, and worth distinguishing from marketing language.
Flow does not replace Amp. Flow orchestrates work through a driver that may use Amp, ReactPHP, Swoole, or Fibers. The demo's measured behavior is a property of the combination: Flow's multi-packet model plus Amp's timer scheduling.
Darkwood Flow
Darkwood Flow composes pipelines too—but "pipeline" here means a graph of transformation nodes connected by packet propagation, not a nested closure onion.
Core concepts
Job — a callable or JobInterface implementing T1 → T2. In our demo, FetchJob transforms FetchRequest into FetchedContent.
Ip (instruction packet) — an immutable wrapper around flowing data:
final class Ip
{
public function __construct(public readonly mixed $data = null) {}
}
Flow — a node with a job, an optional error handler, its own Symfony EventDispatcher, an IP strategy, and an async handler.
Driver — the runtime. FiberDriver by default; AmpDriver in our demo.
await() — a completion barrier. It returns void.
That last point is not a footnote. It shapes the entire developer experience.
Composition
$flow = new Flow($fetchJob, driver: $driver);
$flow->fn($hashJob)->fn($saveJob);
Each fn() call appends a node. Internally, Flow does not build nested closures. It extends a stream structure:
private array $stream = [
'fnFlows' => [], // jobs per node
'dispatchers' => [], // one EventDispatcher per node
];
Each node gets its own dispatcher, IP strategy (FIFO by default via LinearIpStrategy), and AsyncHandler. This is more machinery than Laravel's onion. It buys a different execution model.
Submission and completion
You push work into the flow:
$flow(new Ip($task));
That dispatches a PushEvent on the first node's dispatcher. Nothing blocking happens yet beyond enqueueing.
You complete work with:
$flow->await();
The driver drains the stream. For AmpDriver, that means running the Revolt event loop until no instruction packets remain in flight.
There is no then(). No return value. Results require explicit collection—a terminal job or a dedicated sink.
flowchart LR
subgraph nodes ["Flow nodes"]
N1["FetchJob"]
N2["HashJob"]
N3["SaveJob"]
N4["Collector"]
end
N1 -->|"Ip(FetchedContent)"| N2
N2 -->|"Ip(HashedContent)"| N3
N3 -->|"Ip(SavedResult)"| N4
How execution differs from Laravel
| Laravel Pipeline | Darkwood Flow |
|---|---|
| One passable per invocation | Many Ip packets |
$next delegates explicitly |
Driver propagates between nodes |
| Synchronous call stack | Event-loop or fiber scheduling |
Returns from then() |
await() returns void |
Short-circuit by omitting $next |
No direct equivalent |
| ~30 lines of core onion logic | Nodes, dispatchers, strategies, handlers |
Flow is not "Laravel Pipeline but async." It is a dataflow runtime that happens to express sequential transformation chains.
What happens inside await()
When you call $flow->await(), control passes to the driver. With AmpDriver, the implementation registers a loop on the Revolt event loop. On each tick, it pulls waiting instruction packets from each node's queue, dispatches AsyncEvent to run the node's job inside Amp\async(), and propagates the result to the next node via PushEvent when the job completes.
You do not see this machinery in application code. You do see its effects: multiple Amp\async() jobs can be in flight, their timer-based waits registered on the same loop, while hash and save nodes process packets that have already finished fetching.
That is why Flow carries more concepts than Laravel's onion. The onion hides execution inside closures. Flow makes execution explicit through drivers, dispatchers, and handlers—then pushes complexity out of the composition layer and into the runtime.
We do not oversell it. For a simple synchronous command handler, Flow adds complexity without benefit. Its value appears when you have multiple independent packets and a driver that can overlap their wait time.
Running multiple instruction packets
The most important demo observation is not a number. It is a log pattern.
Synchronous output:
[SYNC][task #1][fetch] start
[SYNC][task #1][fetch] end
[SYNC][task #1][hash] start
...
[SYNC][task #2][fetch] start
Flow output:
[FLOW][task #1][fetch] start
[FLOW][task #2][fetch] start
[FLOW][task #3][fetch] start
[FLOW][task #4][fetch] start
[FLOW][task #5][fetch] start
[FLOW][task #4][fetch] end
[FLOW][task #4][hash] start
...
All five fetch operations start before the slowest fetch ends. That is the visual proof of overlap.
What overlaps
- Timer waits on the fetch node, via
Amp\delay() - Independent instruction packets at the same pipeline stage
- Pipeline-stage overlap as faster tasks advance to hash/save while slower tasks remain in fetch
What does not overlap
- CPU-bound work on a single OS thread. PHP still executes one opcode stream at a time per thread
- Blocking I/O we did not use. If
FetchJobcalled a blocking HTTP client, the event loop would stall unless that client integrates with Revolt - Multiple processes or threads. There is no parallelism in the multicore sense
Vocabulary matters
Use precise terms:
- Concurrency — multiple tasks in flight, their waits interleaved. Yes, demonstrated.
- Parallelism — simultaneous execution on multiple cores. No, not demonstrated.
- Asynchronous scheduling — the event loop resumes tasks when timers complete. Yes, for Amp timers.
- Cooperative multitasking — tasks yield explicitly. Partially; Amp's suspension model cooperates with the loop.
sequenceDiagram
participant Main
participant EL as RevoltEventLoop
participant F1 as Fetch task 1
participant F2 as Fetch task 2
Main->>F1: start delay 0.8s
Main->>F2: start delay 0.3s
Note over F1,F2: both timers registered
EL->>F2: resume after 0.3s
EL->>F1: resume after 0.8s
Five delays complete in roughly the duration of the longest delay because the waits overlap on one thread's event loop—not because PHP spawned five threads.
The measurements
We ran demo:pipeline:compare repeatedly on PHP 8.5.4. Results were stable:
| Run | Sync | Flow | Expected sequential | Overlap |
|---|---|---|---|---|
| 1 | 2.42 s | 0.81 s | 2.40 s | yes |
| 2 | 2.42 s | 0.81 s | 2.40 s | yes |
| 3 | 2.43 s | 0.81 s | 2.40 s | yes |
Across runs:
- Sync average: ~2.42 s (matches sum of fetch delays)
- Flow average: ~0.81 s (matches max fetch delay of 0.8 s)
- Elapsed reduction: ~66% in this specific demonstration
- Variance: negligible (artificial timers, same machine, no I/O noise)
This is a controlled demonstration of scheduling behavior. It is not a benchmark. Do not extrapolate to production throughput, HTTP workloads, or CPU-bound batch processing.
What the numbers demonstrate
- Sequential blocking execution accumulates wait time linearly
- Event-loop-scheduled independent waits overlap on a single thread
- The Flow run completes in roughly the time of the slowest fetch, not the sum of all fetches
- Both implementations produce identical hashes and files (verified by PHPUnit)
What the numbers do not demonstrate
- That Flow is "three times faster" in general
- That Laravel Pipeline is slow or poorly designed
- That async HTTP is "solved."
- That Flow outperforms raw
Amp\async()calls—you could overlap five timers in a short Amp script without Flow's node machinery - That multi-request Laravel applications cannot achieve concurrency—PHP-FPM, Octane, and external queues exist for different layers of the problem
The measurement supports one qualified claim:
When independent tasks spend most of their time in driver-mediated non-blocking waits, Flow's multi-packet model plus an event-loop driver can reduce elapsed time compared to processing each task sequentially through a synchronous onion.
That is scheduling semantics, not library superiority.
gantt
title Sync vs Flow fetch phase
dateFormat X
axisFormat %Ls
section Sync
T1fetch :0, 800
T2fetch :800, 1100
T3fetch :1100, 1700
T4fetch :1700, 1900
T5fetch :1900, 2400
section Flow
T1fetch :0, 800
T2fetch :0, 300
T3fetch :0, 600
T4fetch :0, 200
T5fetch :0, 500
Comparing both models
| Dimension | Laravel Pipeline | Darkwood Flow |
|---|---|---|
| Simplicity | Very high (~30 lines of core logic) | Moderate (nodes, dispatchers, drivers) |
| Debugging | Harder (invisible closure stack) | Harder (event loop + fibers) but nodes are named |
| Typing | Untyped passable; conventions only | Job signatures with template docs |
| Runtime | Synchronous, single-threaded | Driver-dependent; async-capable |
| Composition | through([...]) fluent array |
new Flow() + fn() chain |
| Ergonomics | Excellent for middleware | More boilerplate; manual Ip; await() |
| Learning curve | Low if you know middleware | Higher (packets, drivers, strategies) |
| Extensibility | Subclass, Macroable, Hub |
IP strategies, async handlers, transports |
| Performance (this demo) | ~2.4 s sequential waits | ~0.8 s overlapping waits |
| Performance (general) | Near-zero overhead per step | Event dispatch + scheduling overhead; wins on overlapped I/O waits |
| Symfony integration | N/A (Laravel-native) | Bundle exists; services config empty; manual wiring |
| Result retrieval | then() / thenReturn() |
Terminal collector; await() returns void |
| Multi-item processing | External loop required | Native multiple Ip submission |
| Short-circuit | Omit $next |
No built-in equivalent |
Neither column "wins." They optimize for different problems.
What Flow can learn from Laravel
Flow is a capable runtime. Its developer experience has friction points Laravel solved years ago—accidentally, by keeping the onion minimal.
1. A run() or sink() for outputs
Laravel:
return $pipeline->send($task)->through($pipes)->thenReturn();
Flow today:
foreach ($tasks as $task) { $flow(new Ip($task)); }
$flow->await();
// results in $collector because you wired a terminal fn
await() returns nothing. Forgetting it fails silently from the caller's perspective. A minimal improvement:
$flow->sink($collector);
$flow->pushAll($tasks);
$flow->await();
Or a batch helper:
$results = $flow->runAll($tasks);
Not one run() that pretends multi-packet flows always return a single value—but explicit batch semantics.
2. Symfony bundle wiring
FlowBundle registers an extension with an empty services.php. Our demo manually binds:
Flow\Driver\AmpDriver: ~
Flow\DriverInterface: '@Flow\Driver\AmpDriver'
Minimal bundle support should ship a default driver alias and configurable driver class. No compiler passes required for v1.
3. Tracing via existing events
Flow already dispatches PushEvent, AsyncEvent, PopEvent. A TraceSubscriber could log node enter/exit, packet IDs, and elapsed time—without modifying core. Laravel has no equivalent hooks in its onion. This is an opportunity, not a missing feature in Laravel.
4. FiberDriver delay precision
time()-based delay is an API footgun. Sub-second work needs hrtime() at minimum. This is not a philosophical debate; it is a precision bug for a method named delay(float $seconds).
5. Naming and discoverability
fn() is concise but opaque to PHP developers expecting pipe(), through(), or then(). A fluent alias costs little:
$flow->through($hashJob)->through($saveJob);
Keep fn() for those who want it. Add readability for everyone else.
Choosing the right model
Practical advice, not a verdict.
Choose Laravel Pipeline (or the same onion pattern) when:
- You intercept one passable per invocation (HTTP request, command, queued job)
- Steps are synchronous and comparatively fast
- You need explicit short-circuit control (
$nextomission) - You want the result from the end of the chain via
then() - You are inside Laravel and the primitive is already there
Laravel Pipeline is not trying to be a dataflow engine. Judging it for failing to overlap five artificial timer waits misses its purpose.
Choose Darkwood Flow when:
- You process many independent packets through the same logical stages
- Steps include wait time a driver can schedule non-blockingly
- You need per-node error handling, IP strategies, or backpressure (
MaxIpStrategy) - You may later integrate Messenger transports or distributed nodes (
TransportFlow) - You accept more machinery in exchange for runtime flexibility
Flow is not trying to be middleware. Using it to gate a single HTTP request would be architectural mismatch.
Can they coexist?
Yes. A Laravel middleware stack can remain synchronous while a background import uses Flow with AmpDriver. A command bus method might return a final DTO via thenReturn() while a nightly sync pushes thousands of Ip packets through a hash-and-save flow.
The question is never "which library is better." It is "what execution model does this workload require?"
Worked examples
HTTP API authentication middleware — one request, must short-circuit to 401 before the controller runs, result is a response object. Use an onion with $next. Flow would add packets, drivers, and await() for no gain.
Nightly import of ten thousand URLs — each record fetches, normalizes, and saves; fetch latency dominates; records are independent. A synchronous foreach with usleep() or blocking HTTP will accumulate wait time linearly. A Flow pipeline with AmpDriver and an HTTP client that participates in the event loop is a plausible fit—provided you wire result collection and error handling per node.
Single Artisan command that transforms one DTO — three pure functions in sequence. A private method chain or a tiny onion is enough. Neither Laravel Pipeline nor Flow earns its keep.
The demo sits in the second category, simplified: no HTTP, artificial timers, five packets. It is a teaching instrument, not a deployment template. Re-run demo:pipeline:compare on your own machine before citing the exact timings in production-facing material.
Conclusion
We opened with an overloaded word. Pipeline describes composition. It does not, by itself, describe execution.
Laravel Pipeline composes with a nested closure onion: one passable, explicit $next, synchronous call stack, result returned from then(). It is minimal, battle-tested, and exactly suited to middleware and command interception.
Darkwood Flow composes with linked nodes: instruction packets, driver-mediated scheduling, await() as a barrier, results collected explicitly. It carries more concepts because it targets a different problem—overlapping work across independent packets when the runtime allows.
Our demo made the difference visible:
- 2.42 seconds sequential
- 0.81 seconds overlapping
- identical output
- interleaved logs
Those numbers are not a sales pitch. They are a controlled illustration of a deeper truth:
The important distinction is not Laravel versus Flow. It is composition versus execution.
When you next read "pipeline" in a README, issue title, or architecture document, ask the follow-up question immediately: how does it execute?
If the answer is "one $next at a time," you know what you have. If the answer is "packets through a driver," you know you have something else—something that may overlap waits, and something that demands you wire your results yourself.
Both are pipelines. They are not the same machine.
Demo repository
Clone and run:
composer install
php bin/console demo:pipeline:compare
php bin/phpunit
- Source: laravel-pipeline
- Slides : https://github.com/matyo91/slidewire