Darkwood Blog Blog
  • Articles
  • Auto
  • Releases
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Auto
  • Releases

😶‍🌫️ This person does not exist - Synchronous PHP to asynchronous orchestration with Flow

on June 13, 2026

Log in to add a reaction to this post

🚀 1

You need to download ten images from a remote API. Between each request, you wait one second to respect a rate limit. Each image follows the same path: fetch, hash, save, report.

The code looks like a familiar for loop. It works. But while one image downloads, the other nine wait. While a fiber sleeps in a sleep() function, the entire process is frozen. The units of work are independent—only the orchestration treats them as if they weren't.

This is the concrete problem that we isolated in the flow-thispersondoesnotexist project: a minimal Symfony command that retrieves several images from ThisPersonDoesNotExist and saves them to disk.

The goal was not to download images faster.

The goal was to understand how to evolve a sequential execution into an asynchronous orchestration with darkwood/flow — without changing the business logic, by changing the execution model.

The transition can be summarized in two commits:

  • fae47e8 — command app:fetch-thispersondoesnotexist, synchronous loop
  • c61c929 — same command, orchestrated by Flow and FiberDriver

Only one file changes between the two: src/Command/FetchThisPersonDoesNotExistCommand.php.

The starting point: a synchronous loop

The first version (fae47e8) uses a classic for loop. Fetching, hashing, saving, and reporting are executed inline; a sleep(1) blocks the process between each download:

$savedFiles = [];

for ($index = 1; $index <= $count; ++$index) {
    if ($count > 1) {
        $this->io->section(sprintf('Download %d/%d', $index, $count));
    }

    try {
        $image = $this->fetchImage();
        $checksum = $this->computeChecksum($image);
        $filename = $this->generateFilename($checksum, $file);
        $outputPath = $this->saveFile($filename, $image);
        $this->report($outputPath, $checksum, \strlen($image));
    } catch (\RuntimeException $exception) {
        $this->io->error($exception->getMessage());

        return Command::FAILURE;
    }

    $savedFiles[] = $outputPath;

    if ($index < $count) {
        $this->io->note(sprintf('Waiting %d second(s) before next download...', self::DOWNLOAD_DELAY_SECONDS));
        sleep(self::DOWNLOAD_DELAY_SECONDS);
    }
}

The process is linear: download, process, wait, move on to the next image. Each step completely blocks the process — including the sleep(), which freezes the program even when no useful work is in progress.

This approach is easy to understand, but it wastes I/O waiting time: as long as one image is not finished, no other can advance.

Note: the synchronous version also retained an alternative method fetchImageViaFileGetContents(). This will be removed in the Flow version — explicit streams (fopen) are preferred going forward.

Observe the hidden workflow

Behind the loop lies a pipeline that is repeated for each image:

Fetch Image → Save Image → Report Result
Image #1 : Fetch → Save → Report
Image #2 : Fetch → Save → Report
Image #3 : Fetch → Save → Report

Each image is an autonomous unit of work. The question then becomes: why wait for image #1 to finish completely before starting image #2?

Before/After: The Architectural Change

Before the introduction of Flow, execution was strictly sequential:

Image 1 → Fetch → Save → Sleep
Image 2 → Fetch → Save → Sleep
Image 3 → Fetch → Save → Sleep

After that, the work units are planned independently and go through a common pipeline:

Image 1 ─┐
Image 2 ─┼─> Flow Pipeline (Fetch → Save + Report) ─> résultats
Image 3 ─┘

The for loop no longer controls step-by-step execution. It enqueues Ips (units of work). Flow orchestrates their passage through the pipeline; await() synchronizes at the end.

Introducing Flow

The darkwood/flow dependency is added to the project before the command (e71899a). Version c61c929 finally uses it to orchestrate the work.

Imports introduced:

use Flow\Driver\FiberDriver;
use Flow\ExceptionInterface;
use Flow\FlowFactory;
use Flow\Ip;

Each image becomes an independent unit of work.

In Flow, this unit is represented by an Ip (Instruction Pointer):

$flow(new Ip($index));

The pipeline is described in stages with FlowFactory and a generator:

$driver = new FiberDriver();
$savedFiles = [];

$flow = (new FlowFactory())->create(function () use ($file, &$savedFiles, $driver) {
    yield [
        fn (int $index) => $this->fetchImage($index, $driver),
        fn (ExceptionInterface $exception) => throw new \RuntimeException($exception->getMessage()),
    ];
    yield function (string $image) use ($file, &$savedFiles): string {
        $outputPath = $this->saveAndReport($image, $file);
        $savedFiles[] = $outputPath;

        return $outputPath;
    };
}, ['driver' => $driver]);

for ($index = 1; $index <= $count; ++$index) {
    $flow(new Ip($index));
}

try {
    $flow->await();
} catch (\RuntimeException $exception) {
    $this->io->error($exception->getMessage());

    return Command::FAILURE;
}

Conceptually, this amounts to writing:

Ip($index)
 ↓
Fetch Image        ← étape async (fiber + delay)
 ↓
Save + Report      ← étape suivante, reçoit le string image

Each image passes through exactly the same pipeline.

Responsibilities are extracted using dedicated methods:

  • fetchImage(int $index, FiberDriver $driver): string — download, with cooperative suspension
  • saveAndReport(string $image, ?string $fileOverride): string — hash, save, reporting

Error handling exits the loop: an errorJob on the fetch step triggers a RuntimeException, captured only once around await().

Replace the blocking wait

The most significant change concerns the management of waiting time.

In the synchronous version, the delay is global and blocking:

private const int DOWNLOAD_DELAY_SECONDS = 1;

// dans la boucle, entre chaque image :
sleep(self::DOWNLOAD_DELAY_SECONDS);

In the Flow version, the delay is per fiber and cooperative:

private const int DELAY_MIN_SECONDS = 1;
private const int DELAY_MAX_SECONDS = 3;

// dans fetchImage(), avant le téléchargement :
$delay = random_int(self::DELAY_MIN_SECONDS, self::DELAY_MAX_SECONDS);
$this->io->note(sprintf('#%d: suspending fiber for %ds before download...', $index, $delay));
$driver->delay($delay);

$driver->delay() only suspends the currently running fiber. The other fibers continue:

Image #1 attend (delay 2s)
Image #2 télécharge
Image #3 sauvegarde
Image #4 démarre son delay

With --count=5, the total time approaches max(delays + fetch) rather than their sum.

Fibers as a foundation for execution

Under the hood, FiberDriver relies on PHP 8.1 Fibers: a Fiber can be suspended and then resumed later, allowing several independent processes to be executed with writing close to classic PHP.

$driver = new FiberDriver();

Each image has its own Fiber. When one Fiber waits ($driver->delay()), the others continue their progress. The logging reflects this behavior:

#2: suspending fiber for 2s before download...
#2: downloading (other fibers may run while this one waits)...
#2: fetch complete in 1.34s

Flow does not replace Fibers: it uses them via a driver. The choice of driver determines how the pipeline runs; the pipeline description remains the same.

The synchronization point

The for query identifies the work units:

for ($index = 1; $index <= $count; ++$index) {
    $flow(new Ip($index));
}

await() becomes the single synchronization point:

$flow->await();
Planifier Ip(1)
Planifier Ip(2)
Planifier Ip(3)
…
await() — barrière finale

The responsibility for orchestration is shifted to Flow. The for loop no longer performs the task: it enqueues units. Flow executes them in parallel; await() waits for the entire pipeline to finish.

Why keep PHP Streams?

The project intentionally uses:

$stream = fopen(self::FETCH_URL, 'r', false, $context);
$content = stream_get_contents($stream);

rather than file_get_contents().

The goal is not to reinvent an HTTP client.

The goal is to stay close to the PHP primitives — the code comment explicitly states this:

Preferred for Flow experimentation: the resource handle is the primitive that can later be wired to stream_select(), non-blocking mode, or fibers.

These primitives can then evolve into:

stream_set_blocking(false);
stream_select(...);

or be integrated into more advanced drivers (Amp, React, Swoole — all supported by darkwood/flow).

Streams provide a natural basis for experimenting with asynchronous models in PHP.

Limiting competition (next step)

In this POC, all images are sent in parallel (up to --count active fibers).

For production use, Flow offers strategies such as MaxIpStrategy:

use Flow\IpStrategy\MaxIpStrategy;

yield [$job1, $errorJob1, new MaxIpStrategy(5)];

The official example package (examples/flow.php) uses MaxIpStrategy(2) to cap the number of simultaneous jobs on each step.

It's not yet connected to flow-thispersondoesnotexist, but it's the natural lever for rate limiting without falling back into sequential sleep().

A model applicable far beyond images

The ThisPersonDoesNotExist example is intentionally simple. The same pattern is found in Darkwood pipelines:

Scraping YouTube     → Lister → Télécharger / transcrire → Sauvegarder
Traitement vidéo     → Générer assets → Encoder → Persister
Agent IA             → Lire → Transformer → Publier

In all cases: independent work units that go through a succession of steps. Flow does not seek to execute a task — it orchestrates flows of tasks.

Migration Summary

Appearance fae47e8 (synchronous) c61c929 (Flow)
Loop for with fetch/save inline for enqueue + $flow->await()
Delay sleep(1) global $driver->delay() per fiber
Errors try/catch in the loop errorJob + catch on await()
Fetch fetchImage() fetchImage($index, $driver)
Save inline saveAndReport()
Competition none parallel fibers via FiberDriver

What Flow is not

Before concluding, a useful clarification to avoid common confusions.

Flow is not an event loop. It does not directly handle a select / poll cycle on I/O descriptors.

Flow is not an implementation of Fibers. Fibers are a PHP 8.1 primitive. Flow orchestrates them via drivers, but does not replace them.

Flow is not a runtime. It does not modify the PHP execution model. It runs in a standard PHP process.

Flow is not a replacement for Amp, ReactPHP, or Swoole. These libraries provide runtimes and event loops. Flow can rely on them—AmpDriver, ReactDriver, SwooleDriver—without competing with them.

What Flow provides is a model of orchestration:

  • describe units of work (Ip)
  • compose pipelines of steps (yield in FlowFactory)
  • Delegate the execution to a chosen driver (FiberDriver, AmpDriver, ReactDriver, SwooleDriver, …)

The orchestration model is separate from the execution model. A pipeline can be described once, and then the driver can be changed depending on the context — CLI with Fibers, Swoole worker, React service — without rewriting the business logic.

Conclusion

The shift from synchronous to asynchronous is not primarily a question of performance.

It's a change in mental model:

Avant : exécuter l'opération A, puis B, puis C — et attendre entre chaque étape.

Après : décrire un pipeline, enqueue des unités de travail,
        laisser l'orchestrateur planifier l'exécution.

The for loop becomes an IP producer. The fetch and save steps become chained jobs. await() replaces the sequence of sleep() calls. The business logic (download, hash, save) remains the same; what changes is who decides when each step executes.

This is exactly the model Darkwood needs for its upcoming pipelines: YouTube scraping and transcript extraction, media processing in MediaBundle, multi-stage AI agents, and orchestration of long-running workflows. Independent units, composable stages, and an explicit synchronization point.

The ThisPersonDoesNotExist project is the minimal demonstration: a single file, a two-step pipeline, a driver — sufficient to illustrate the transition without obscuring its scope.

To go further

  • POC repository: flow-thispersondoesnotexist — commits fae47e8 → c61c929
  • Package: darkwood/flow — example examples/flow.php
  • Frédéric Bouchery's article traces the history of asynchronous programming in PHP, from the streams introduced in PHP 4.3 to the modern Fibers of PHP 8.1: https://f2r.github.io/fr/asynchrone.html

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved