Darkwood Blog Blog
  • Articles
  • Veille
  • Releases
  • Créateurs
fr
  • de
  • en
Connexion
  • Blog
  • Articles
  • Veille
  • Releases
  • Créateurs

⚖️ Laravel Pipeline et Darkwood Flow : deux modèles d’exécution pour composer des étapes PHP

le 11 juillet 2026

Connectez-vous pour réagir à cet article

🚀 1

La plupart des développeurs PHP ont déjà utilisé un pipeline. Rares sont ceux qui se sont demandés de quel type de pipeline il s'agissait.

Le terme « pipeline » peut désigner un système de pipelines Unix, une étape CI/CD, une pile de middleware ou une chaîne ETL. Il décrit la composition : étape A, puis étape B, puis étape C. En revanche, il ne décrit pas l’exécution : si ces étapes s’exécutent successivement sur un seul thread, si elles peuvent se chevaucher, si elles traitent une ou plusieurs valeurs, ou si une étape peut interrompre la chaîne prématurément.

Les composants Illuminate\Pipeline de Laravel et Flow de Darkwood répondent tous deux à la question de la composition. En revanche, ils répondent très différemment à la question de l'exécution.

Cet article n'est ni un tutoriel Laravel, ni un tutoriel Symfony. Il ne s'agit pas d'un test de performance, ni d'une publicité pour l'une ou l'autre de ces bibliothèques. Il explique deux modèles d'exécution qui, par ailleurs, s'organisent selon une structure en pipeline, et démontre en quoi cette distinction est importante en pratique.

L'idée centrale est simple :

Un pipeline définit la composition des tâches. L'environnement d'exécution détermine la manière dont ces tâches sont exécutées.

À la fin, vous devriez être capable d'examiner un « pipeline » dans n'importe quel code source PHP et de poser la bonne question : non pas « quelles sont les étapes ? » mais « quel est le modèle d'exécution ? »

Introduction : un mot surchargé

Demandez à trois développeurs PHP expérimentés ce qu'est un pipeline, et vous obtiendrez probablement trois réponses compatibles mais incomplètes.

On décrira le middleware : une requête passe par l’authentification, la gestion de session et le routage avant d’atteindre un contrôleur. C’est le modèle mental dominant de Laravel.

Un autre texte décrira le fonctionnement des pipes Unix : la sortie standard d'un processus devient l'entrée standard du suivant. Les données circulent de gauche à droite ; chaque étape est bloquée jusqu'à ce qu'elle ait produit une sortie.

Une troisième partie décrira l’orchestration : une file d’attente de tâches, un importateur par lots ou une chaîne de traitement de données où de nombreux enregistrements suivent les mêmes étapes logiques, éventuellement simultanément.

Ces trois pipelines sont au sens compositionnel du terme. Aucun ne spécifie la même sémantique d'exécution.

Cet écart devient criant lorsqu'on compare des bibliothèques. Laravel Pipeline est couramment décrit comme « l'implémentation des middlewares ». Darkwood Flow est décrit comme « un pipeline asynchrone ». Ces deux étiquettes sont justes, mais insuffisantes. Un middleware est un cas d'utilisation. L'asynchrone est une propriété d'exécution. La question de fond est structurelle :

Qu'est-ce qui caractérise un pipeline ?

Un pipeline nécessite au minimum :

  1. Étapes ordonnées — une séquence définie de transformations ou d'intercepteurs
  2. Une valeur en flux continu — quelque chose qui passe d'une étape à l'autre (une requête, une commande, un paquet de données)
  3. Un mécanisme de composition — une manière de déclarer ou d'assembler ces étapes

Laravel Pipeline ajoute un quatrième ingrédient qui définit son modèle d'exécution :

  1. Une fonction de rappel $next explicite — chaque étape reçoit la valeur actuelle et une référence au reste de la chaîne

Darkwood Flow remplace cela par un quatrième ingrédient différent :

  1. Un environnement d'exécution piloté par un pilote — les étapes sont des nœuds ; les valeurs sont des paquets d'instructions (Ip) ; l'achèvement est coordonné par une boucle d'événements ou un planificateur de fibres.

Même mot. Contrats d'exécution différents.

Pour illustrer cela concrètement, nous avons créé une petite démo dans la console Symfony qui exécute le même pipeline logique (récupération, hachage, sauvegarde) avec les deux modèles, mesure le temps écoulé et enregistre l'ordre d'exécution. Cette démo est volontairement simple. Son but n'est pas de désigner un vainqueur, mais de rendre visible la différence d'exécution.

Pipeline Laravel en une trentaine de lignes

Le pipeline de Laravel est l'un des mécanismes les plus élégants du framework, et l'un des plus mal compris, car son élégance est cachée à l'intérieur de fermetures imbriquées.

L'ensemble du mécanisme d'exécution de Illuminate\Pipeline\Pipeline se résume à un constructeur fluide et à un appel à array_reduce. Voici le cœur de then() :

public function then(Closure $destination)
{
    $pipeline = array_reduce(
        array_reverse($this->pipes()),
        $this->carry(),
        $this->prepareDestination($destination)
    );

    return $pipeline($this->passable);
}

Lisez attentivement. Trois opérations sont importantes :

  1. array_reverse($this->pipes()) — la liste des pipes est inversée avant la réduction
  2. $this->carry() — un réducteur qui enroule chaque pipe autour de la pile accumulée
  3. $destination — la couche la plus interne, généralement un répartiteur de route ou un gestionnaire de commandes

Si vous déclarez through([A, B, C]), la pile construite est A(B(C($destination))). A s'exécute en premier. Ceci inverse l'ordre intuitif d'une liste pour quiconque s'attend à ce que le premier élément du tableau soit la couche la plus interne. C'est correct pour les intergiciels : le premier intergiciel enregistré est la porte la plus externe.

Notre démo reproduit ce mécanisme dans une classe simplifiée, sans conteneur, sans gestion des exceptions et sans rappel finally :

public function then(Closure $destination): mixed
{
    $pipeline = array_reduce(
        array_reverse($this->pipes),
        $this->carry(),
        $destination,
    );

    return $pipeline($this->passable);
}

Voilà, c'est tout. Tout le reste dans la classe Pipeline complète de Laravel (résolution des conteneurs, via('handle'), withinTransaction(), Macroable, Conditionable) est important pour l'intégration du framework, mais pas pour le modèle d'exécution lui-même.

Pourquoi l'oignon est élégant

Le pipeline ne conserve pas de liste explicite de l'« étape courante » lors de l'exécution. Il n'itère pas. Il regroupe les flux en une seule fonction appelable. Chaque flux reçoit :

  • la valeur actuelle acceptable
  • une fermeture $next représentant le reste de la chaîne

Pas d'itérateur partagé. Pas de coordinateur central. Pas de bus d'événements. Uniquement des fonctions imbriquées.

flowchart LR
    input["passable"] --> pipeA["Pipe A"]
    pipeA --> pipeB["Pipe B"]
    pipeB --> pipeC["Pipe C"]
    pipeC --> dest["destination"]

Pour through([A, B, C]), la construction fonctionne comme ceci :

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)))"]

Cette conception a des conséquences sur lesquelles nous reviendrons : elle est synchrone, à passage unique par défaut et extrêmement prévisible.

La signification de $next

Un pipe Laravel n'est pas simplement function ($value) { ... }. Sa signature est :

function ($passable, $next) { ... }

L'argument $next correspond au reste du pipeline, déjà intégré dans une fermeture. Appeler $next($passable) délègue aux couches internes. Retourner sans appeler $next court-circuite la chaîne : la destination et tous les pipelines en aval sont ignorés.

Il ne s'agit pas d'un détail d'implémentation. C'est le contrat qui permet au middleware de fonctionner.

Avant et après

Un pipeline peut exécuter une logique avant la délégation :

function ($request, $next) {
    // before: authenticate, mutate headers, log
    return $next($request);
}

Un tube peut exécuter une logique après délégation en capturant la valeur de retour :

function ($request, $next) {
    $response = $next($request);
    // after: add headers, transform response
    return $response;
}

Un tuyau peut transformer ce qui arrive en aval :

function ($request, $next) {
    return $next($modifiedRequest);
}

Une canalisation peut se terminer prématurément :

function ($request, $next) {
    return response('Forbidden', 403);
    // $next never called
}

Les propres tests de Laravel confirment la sémantique de court-circuit : si un pipeline précoce se termine sans appeler $next, les pipelines suivants et la destination ne s'exécutent jamais.

Pourquoi ce modèle convient à HTTP

Laravel configure le noyau HTTP de la manière suivante :

return (new Pipeline($this->app))
    ->send($request)
    ->through($this->middleware)
    ->then($this->dispatchToRouter());

Une seule requête. Une seule requête réussie. Un seul cycle synchrone. Chaque couche intermédiaire décide de la suite à donner à la requête. Si l'authentification échoue, la fonction $next n'est pas appelée ; le routeur n'est jamais exécuté. C'est précisément le flux de contrôle dont HTTP a besoin.

Le même mécanisme primitif alimente les bus de commandes et les intergiciels de gestion des tâches en file d'attente. Le pipeline n'est pas une fonctionnalité HTTP de Laravel. Il s'agit d'une primitive d'interception synchrone réutilisée dans différents sous-systèmes.

Le composant Bus\Dispatcher de Laravel envoie chaque commande via le même algorithme en oignon avant l'exécution du gestionnaire :

return $this->pipeline->send($command)->through($this->pipes)->then($callback);

Les tâches en file d'attente reçoivent une pile de middleware par tâche dans CallQueuedHandler :

return (new Pipeline($this->container))->send($command)
    ->through($command->middleware())
    ->then(function ($command) { /* dispatch handler */ });

Dans tous les cas, la structure est identique : une seule chaîne synchrone, une seule valeur de retour. Laravel ne demande pas à Pipeline de chevaucher cinq commandes. Il lui demande d’intercepter une seule commande lors de son acheminement vers un gestionnaire. Ce choix de conception est essentiel pour la robustesse du pipeline.

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

Il n'y a qu'une seule pile d'appels. Un seul thread. Un seul traitement est possible à la fois. Ce n'est pas une limitation pour la gestion des requêtes. C'est même le principe.

Création de la démo

Pour comparer équitablement les modèles d'exécution au niveau du code, nous avons construit un projet Symfony Console avec deux implémentations du même pipeline logique :

Fetch content → Hash content → Save result

Le dépôt se trouve à l'emplacement suivant : darkwood/content/laravel-pipeline. Il nécessite PHP 8.5, Symfony 8.1, darkwood/flow et amph/amp.

Ce comportement est mis en évidence par trois commandes :

php bin/console demo:pipeline:sync
php bin/console demo:pipeline:flow
php bin/console demo:pipeline:compare

Les étapes du pipeline

Fetch simule la latence d'E/S, puis produit un contenu déterministe : content-for-task-{id}.

La fonction Hash calcule le hachage sha256 du contenu. Elle est quasi instantanée.

Enregistrer crée un fichier JSON dans var/demo/task-{id}.json. L'opération est instantanée.

Pourquoi des retards contrôlés

Nous avions besoin de preuves reproductibles du comportement de planification. Les points de terminaison HTTP réels introduisent des variations DNS, des échanges TLS et des fluctuations du réseau — autant de perturbations qui masquent le phénomène que nous cherchons à expliquer.

En revanche, cinq tâches utilisent des délais de récupération fixes :

Tâche Délai
1 0,8 s
2 0,3 s
3 0,6 s
4 0,2 s
5 0,5 s

La somme est de 2,4 secondes. Si les tâches s'exécutent séquentiellement et que chaque récupération dure toute la durée prévue, le temps total de récupération devrait avoisiner 2,4 s. Si des récupérations indépendantes se chevauchent dans une boucle d'événements, le temps total devrait avoisiner 0,8 s, soit le délai le plus long.

Nous avons délibérément non utilisé de protocole HTTP externe. Nous n'avons non utilisé sleep() ni usleep() côté Flow, car bloquer l'intégralité du processus PHP n'aurait rien prouvé concernant la planification de la boucle d'événements.

L'implémentation synchrone utilise usleep() dans son pipeline de récupération. Ce choix est intentionnel : il modélise les E/S séquentielles bloquantes telles qu'une pile d'intergiciels synchrone les appréhende.

Deux coureurs, une seule rencontre

Le processus synchrone exécute cinq tâches et lance une nouvelle instance d'onion pour chacune d'elles :

foreach ($this->taskProvider->getTasks() as $task) {
    $result = $pipeline
        ->send($task)
        ->through([$this->fetchPipe, $this->hashPipe, $this->savePipe])
        ->thenReturn();
}

Il est important de comprendre cette boucle externe. Le noyau HTTP de Laravel envoie une seule requête à travers une seule boucle oignon. Notre exemple de synchronisation envoie cinq tâches à travers cinq boucles oignon séquentielles. Ce n'est pas ainsi que Laravel gère les requêtes HTTP simultanées ; PHP-FPM le fait avec plusieurs processus. Notre exemple isole le modèle d'exécution : « Que se passe-t-il si je traite un lot séquentiellement en utilisant un pipeline par élément ? »

Le Flow Runner soumet cinq paquets d'instructions dans un flux :

$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();

Mêmes étapes. Mêmes données. Contrat d'exécution différent.

Une idée fausse courante concernant les fibres

PHP 8.1 a introduit Fibers. PHP 8.3 et 8.4 ont apporté une infrastructure plus axée sur l'asynchrone. L'enthousiasme était au rendez-vous, tout comme la confusion.

Les fibres ne rendent pas le code bloquant asynchrone.

Une fibre est une continuation planifiable de manière coopérative. Elle peut se suspendre et reprendre. C'est un outil puissant, mais pas magique. Si votre tâche appelle sleep(), usleep(), un appel bloquant à file_get_contents() ou un client HTTP synchrone, vous bloquez le thread du système d'exploitation sous-jacent, à moins qu'un autre élément de votre environnement d'exécution ne puisse prendre le relais ; or, l'appel système bloqué maintient toujours le thread actif.

Les développeurs confondent trois concepts distincts :

  1. Exécution séquentielle bloquante — une seule tâche s'exécute ; toutes les autres attendent.
  2. Concurrence coopérative — plusieurs tâches logiques s'entrelacent lorsqu'elles cèdent explicitement la priorité.
  3. Exécution parallèle — plusieurs tâches s'exécutent simultanément sur plusieurs cœurs de processeur ou threads du système d'exploitation

Le pilote par défaut de Darkwood Flow est FiberDriver. Son implémentation de delay() ressemble à ceci :

public function delay(float $seconds): void
{
    $date = time();
    do {
        Fiber::suspend();
    } while (time() - $date < $seconds);
}

Deux détails sont importants pour les praticiens :

— Il utilise time(), et non hrtime(). La précision inférieure à la seconde est peu fiable.

  • Il cède le relais de manière coopérative à l'intérieur de la boucle principale de Flow, mais il n'est pas équivalent à une minuterie de boucle d'événements.

Pour une démonstration avec des délais de 300 ms et 800 ms, FiberDriver n'est pas l'outil approprié. Nous avons choisi AmpDriver, qui délègue à Amp\delay() :

public function delay(float $seconds): void
{
    delay($seconds);
}

En interne, le délai d'Amp suspend la coroutine en cours et enregistre un minuteur sur la boucle d'événements de Revolt :

function delay(float $timeout, ...): void
{
    $suspension = EventLoop::getSuspension();
    $callbackId = EventLoop::delay($timeout, static fn () => $suspension->resume());
    // ...
    $suspension->suspend();
}

La tâche logique en cours est en attente. La boucle d'événements, elle, ne l'est pas. Les autres tâches dont les temporisateurs ou les événements d'E/S sont prêts peuvent alors se poursuivre.

Il s'agit d'une ordonnancement asynchrone sur un seul thread du système d'exploitation. Ce n'est pas du parallélisme. Ce n'est pas du « véritable HTTP asynchrone » à moins que votre client HTTP ne participe à la même boucle d'événements. C'est une méthode légitime et précise, qu'il est important de distinguer du jargon marketing.

Flow ne remplace pas Amp. Flow orchestre le traitement via un pilote qui peut utiliser Amp, ReactPHP, Swoole ou Fibers. Le comportement observé dans la démo est une propriété de la combinaison : le modèle multi-paquets de Flow et la planification par minuteur d'Amp.

Flux de Darkwood

Darkwood Flow compose également des pipelines, mais « pipeline » désigne ici un graphe de nœuds de transformation connectés par propagation de paquets, et non un oignon de fermeture imbriqué.

Concepts fondamentaux

Tâche — une fonction ou JobInterface implémentant T1 → T2. Dans notre exemple, FetchJob transforme FetchRequest en FetchedContent.

Ip (paquet d'instructions) — une enveloppe immuable autour des données en flux continu :

final class Ip
{
    public function __construct(public readonly mixed $data = null) {}
}

Flow — un nœud avec une tâche, un gestionnaire d'erreurs optionnel, son propre EventDispatcher Symfony, une stratégie IP et un gestionnaire asynchrone.

Pilote — l'environnement d'exécution. FiberDriver par défaut ; AmpDriver dans notre démo.

await() — une barrière de complétion. Elle ne renvoie rien.

Ce dernier point n'est pas une simple note de bas de page. Il façonne l'expérience de développement dans son ensemble.

Composition

$flow = new Flow($fetchJob, driver: $driver);
$flow->fn($hashJob)->fn($saveJob);

Chaque appel à fn() ajoute un nœud. En interne, Flow ne construit pas de fermetures imbriquées. Il étend une structure de flux :

private array $stream = [
    'fnFlows' => [],      // jobs per node
    'dispatchers' => [],  // one EventDispatcher per node
];

Chaque nœud dispose de son propre répartiteur, de sa propre stratégie d'adressage IP (FIFO par défaut via LinearIpStrategy) et de son propre gestionnaire asynchrone (AsyncHandler). Il s'agit d'une architecture plus complexe que celle de Laravel, reposant sur un modèle d'exécution différent.

Soumission et finalisation

Vous intégrez le travail au flux :

$flow(new Ip($task));

Cela déclenche un événement PushEvent sur le répartiteur du premier nœud. Aucune autre opération bloquante n'est effectuée pour le moment, hormis la mise en file d'attente.

Vous terminez votre travail avec :

$flow->await();

Le pilote vide le flux. Pour AmpDriver, cela signifie exécuter la boucle d'événements Revolt jusqu'à ce qu'il ne reste plus aucun paquet d'instructions en transit.

Il n'existe pas de fonction then(). Aucune valeur de retour. Les résultats nécessitent une collecte explicite : une tâche terminale ou un récepteur dédié.

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

En quoi l'exécution diffère-t-elle de celle de Laravel ?

Pipeline Laravel Flux Darkwood
Un seul passage par invocation Plusieurs paquets Ip
$next délègue explicitement Le pilote se propage entre les nœuds
Pile d'appels synchrone Ordonnancement par boucle d'événements ou par fibre
Valeur de retour de then() await() ne renvoie rien
Court-circuit en omettant $next Pas d'équivalent direct
~30 lignes de logique de base en oignon Nœuds, répartiteurs, stratégies, gestionnaires

Flow n'est pas « Laravel Pipeline en version asynchrone ». Il s'agit d'un environnement d'exécution de flux de données qui permet d'exprimer des chaînes de transformations séquentielles.

Que se passe-t-il à l'intérieur de await()

Lorsque vous appelez $flow->await(), le contrôle est transféré au pilote. Avec AmpDriver, l'implémentation enregistre une boucle sur la boucle d'événements Revolt. À chaque cycle, elle extrait les paquets d'instructions en attente de la file d'attente de chaque nœud, déclenche AsyncEvent pour exécuter la tâche du nœud dans Amp\async(), et propage le résultat au nœud suivant via PushEvent une fois la tâche terminée.

Ce mécanisme n'est pas visible dans le code de l'application. Vous en constatez cependant les effets : plusieurs tâches Amp\async() peuvent être en cours d'exécution, leurs attentes basées sur une minuterie étant enregistrées dans la même boucle, tandis que les nœuds de hachage et de sauvegarde traitent des paquets dont la récupération est déjà terminée.

C'est pourquoi Flow intègre davantage de concepts que le modèle en oignon de Laravel. Ce dernier masque l'exécution au sein de fermetures. Flow, quant à lui, explicite l'exécution grâce aux pilotes, aux répartiteurs et aux gestionnaires, déplaçant ainsi la complexité de la couche de composition vers l'environnement d'exécution.

Nous ne le surestimons pas. Pour un simple gestionnaire de commandes synchrones, Flow ajoute de la complexité sans apporter de bénéfice. Son intérêt se révèle lorsqu'il y a plusieurs paquets indépendants et un pilote capable de gérer le chevauchement de leurs temps d'attente.

Exécution de plusieurs paquets d'instructions

L'observation la plus importante de cette démonstration n'est pas un nombre, mais une tendance logarithmique.

Sortie synchrone :

[SYNC][task #1][fetch] start
[SYNC][task #1][fetch] end
[SYNC][task #1][hash] start
...
[SYNC][task #2][fetch] start

Sortie du flux :

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

Les cinq opérations de récupération démarrent avant la fin de la récupération la plus lente. C'est la preuve visuelle du chevauchement.

Ce qui se chevauche

  • Le minuteur attend sur le nœud de récupération, via Amp\delay()
  • Paquets d'instructions indépendants au même stade du pipeline
  • Chevauchement des étapes du pipeline : les tâches plus rapides passent à l’étape de hachage/sauvegarde tandis que les tâches plus lentes restent en phase de récupération.

Ce qui ne se chevauche pas

  • Tâches gourmandes en ressources CPU sur un seul thread du système d'exploitation. PHP exécute toujours un flux d'opcode à la fois par thread. Nous n'avons pas utilisé d'E/S bloquantes. Si FetchJob appelait un client HTTP bloquant, la boucle d'événements se bloquerait, sauf si ce client est intégré à Revolt.
  • Plusieurs processus ou threads. Il n'y a pas de parallélisme au sens multicœur.

Le vocabulaire est important

Utilisez des termes précis :

  • Concurrence — plusieurs tâches en cours d'exécution, leurs temps d'attente s'entremêlant. Oui, démontré.
  • Parallélisme — exécution simultanée sur plusieurs cœurs. Non, non démontré.
  • Planification asynchrone : la boucle d'événements reprend les tâches une fois les minuteurs terminés. Oui, pour les minuteurs Amp.
  • Multitâche coopératif — les tâches cèdent explicitement le pas. Partiellement ; le modèle de suspension d'Amp coopère avec la boucle.
sequenceDiagram
    participant Main
    participant Loop 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
    Loop->>F2: resume after 0.3s
    Loop->>F1: resume after 0.8s

Cinq délais s'achèvent à peu près en la même durée que le délai le plus long, car les attentes se chevauchent sur la boucle d'événements d'un seul thread, et non parce que PHP a créé cinq threads.

Les mesures

Nous avons exécuté demo:pipeline:compare à plusieurs reprises sur PHP 8.5.4. Les résultats étaient stables :

Exécution Synchronisation Flux Séquence attendue Chevauchement
1 2,42 s 0,81 s 2,40 s oui
2 2,42 s 0,81 s 2,40 s oui
3 2,43 s 0,81 s 2,40 s oui

Sur l'ensemble des courses :

  • Délai de synchronisation moyen : ~2,42 s (correspond à la somme des délais de récupération)
  • Débit moyen : ~0,81 s (correspond au délai de récupération maximal de 0,8 s)
  • Réduction écoulée : ~66 % dans cette démonstration spécifique
  • Variation : négligeable (minuteries artificielles, même machine, pas de bruit d'E/S)

Il s'agit d'une démonstration contrôlée du comportement de planification. Ce n'est pas un test de performance. Ne pas extrapoler aux débits de production, aux charges de travail HTTP ou au traitement par lots gourmand en ressources CPU.

Ce que les chiffres démontrent

  1. L'exécution séquentielle bloquante accumule le temps d'attente de manière linéaire.
  2. Les attentes indépendantes planifiées par la boucle d'événements se chevauchent sur un seul thread.
  3. L'exécution du flux s'achève en un temps approximativement égal à celui de la récupération la plus lente, et non à la somme de tous les temps de récupération.
  4. Les deux implémentations produisent des hachages et des fichiers identiques (vérifié par PHPUnit).

Ce que les chiffres ne démontrent pas

  1. Ce flux est généralement « trois fois plus rapide ».
  2. Que le pipeline Laravel est lent ou mal conçu
  3. Le problème du protocole HTTP asynchrone est « résolu ».
  4. Flow surpasse les appels bruts à Amp\async() : vous pourriez superposer cinq minuteurs dans un court script Amp sans utiliser le mécanisme Node de Flow.
  5. Les applications Laravel multi-requêtes ne peuvent pas atteindre la concurrence ; PHP-FPM, Octane et les files d'attente externes existent pour les différentes couches du problème.

La mesure appuie une allégation conditionnelle :

Lorsque des tâches indépendantes passent la majeure partie de leur temps dans des attentes non bloquantes gérées par le pilote, le modèle multi-paquets de Flow associé à un pilote de boucle d'événements peut réduire le temps écoulé par rapport au traitement séquentiel de chaque tâche via un oignon synchrone.

Il s'agit de sémantique de planification, et non de supériorité de la bibliothèque.

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

Comparaison des deux modèles

Dimension Pipeline Laravel Flux Darkwood
Simplicité Très élevée (~30 lignes de logique principale) Modérée (nœuds, répartiteurs, pilotes)
Débogage Plus complexe (pile de fermeture invisible) Plus complexe (boucle d'événements + fibres) mais les nœuds sont nommés
Saisie au clavier Acceptable sans saisie ; conventions uniquement Signatures de poste avec documents modèles
Exécution Synchrone, monothread Dépend du pilote ; compatible asynchrone
Composition Tableau fluide through([...]) Chaîne new Flow() + fn()
Ergonomie Excellent pour les intergiciels Plus de code répétitif ; Ip manuel ; await()
Courbe d'apprentissage Faible si vous connaissez les intergiciels Plus élevée (paquets, pilotes, stratégies)
Extensibilité Sous-classe, Macroable, Hub Stratégies IP, gestionnaires asynchrones, transports
Performances (cette démo) ~2,4 s d'attente séquentielle ~0,8 s d'attente chevauchante
Performances (générales) Surcharge quasi nulle par étape Surcharge liée à la répartition des événements et à la planification ; gains sur les temps d'attente d'E/S superposés
Intégration Symfony N/A (Laravel natif) Bundle existant ; configuration des services vide ; câblage manuel
Récupération du résultat then() / thenReturn() Collecteur terminal ; await() ne renvoie rien
Traitement multi-éléments Boucle externe requise Soumission native de plusieurs adresses IP
Court-circuit Omettre $next Aucun équivalent intégré

Aucune des deux colonnes ne « gagne ». Elles optimisent pour des problèmes différents.

Ce que Flow peut apprendre de Laravel

Flow est un environnement d'exécution performant. Son expérience de développement présente des points de friction que Laravel a résolus il y a des années, presque par hasard, en minimisant l'architecture en oignon.

1. Une fonction run() ou sink() pour les sorties

Laravel :

return $pipeline->send($task)->through($pipes)->thenReturn();

Flux aujourd'hui :

foreach ($tasks as $task) { $flow(new Ip($task)); }
$flow->await();
// results in $collector because you wired a terminal fn

La fonction await() ne renvoie rien. L'oublier entraîne une erreur silencieuse pour l'appelant. Une amélioration minimale :

$flow->sink($collector);
$flow->pushAll($tasks);
$flow->await();

Ou un assistant de traitement par lots :

$results = $flow->runAll($tasks);

Pas une seule fonction run() qui prétend que les flux multi-paquets renvoient toujours une seule valeur, mais une sémantique de lot explicite.

2. Câblage du faisceau Symfony

FlowBundle enregistre une extension avec un fichier services.php vide. Notre démo effectue les liaisons manuellement :

Flow\Driver\AmpDriver: ~
Flow\DriverInterface: '@Flow\Driver\AmpDriver'

La prise en charge minimale des paquets doit inclure un alias de pilote par défaut et une classe de pilote configurable. Aucune passe de compilation n'est requise pour la version 1.

3. Suivi via les événements existants

Flow gère déjà les événements PushEvent, AsyncEvent et PopEvent. Un TraceSubscriber pourrait enregistrer les entrées/sorties des nœuds, les identifiants des paquets et le temps écoulé, sans modifier le noyau. Laravel ne propose pas d'équivalent dans son architecture. Il s'agit d'une opportunité, et non d'une fonctionnalité manquante de Laravel.

4. Précision du délai du FiberDriver

L'utilisation de time() pour le délai est une erreur fondamentale de l'API. Pour des calculs inférieurs à la seconde, il faut au minimum utiliser hrtime(). Il ne s'agit pas d'un débat philosophique, mais d'un problème de précision de la méthode delay(float $seconds).

  1. Nommage et découvrabilité

La fonction fn() est concise mais opaque pour les développeurs PHP qui s'attendent à utiliser pipe(), through() ou then(). Un alias fluide est peu coûteux :

$flow->through($hashJob)->through($saveJob);

Conservez fn() pour ceux qui le souhaitent. Améliorez la lisibilité pour tous les autres.

Choisir le bon modèle

Des conseils pratiques, pas un verdict.

Choisissez Laravel Pipeline (ou le même modèle en oignon) lorsque :

  • Vous interceptez une seule entrée transmissible par invocation (requête HTTP, commande, tâche en file d'attente).
  • Les étapes sont synchrones et relativement rapides
  • Vous avez besoin d'un contrôle de court-circuit explicite (omission de $next)
  • Vous souhaitez obtenir le résultat de la fin de la chaîne via then() Vous êtes dans Laravel et la primitive est déjà présente.

Laravel Pipeline n'a pas vocation à être un moteur de flux de données. Le critiquer pour son incapacité à gérer le chevauchement de cinq attentes artificielles est une erreur d'appréciation.

Choisissez Darkwood Flow lorsque :

  • Vous traitez de nombreux paquets indépendants à travers les mêmes étapes logiques
  • Les étapes comprennent le temps d'attente qu'un chauffeur peut planifier sans bloquer la circulation.
  • Vous avez besoin d'une gestion des erreurs par nœud, de stratégies d'adressage IP ou d'une gestion de la contre-pression (MaxIpStrategy).
  • Vous pourrez ultérieurement intégrer des transports Messenger ou des nœuds distribués (TransportFlow).
  • Vous acceptez davantage de machines en échange d'une plus grande flexibilité des horaires d'exécution

Flow n'a pas vocation à être un middleware. L'utiliser pour filtrer une seule requête HTTP constituerait une incohérence architecturale.

Peuvent-ils coexister ?

Oui. Une pile de middlewares Laravel peut rester synchrone tandis qu'une importation en arrière-plan utilise Flow avec AmpDriver. Une méthode de bus de commandes peut renvoyer un DTO final via thenReturn() tandis qu'une synchronisation nocturne achemine des milliers de paquets Ip via un flux de hachage et d'enregistrement.

La question n'est jamais de savoir « quelle bibliothèque est la meilleure », mais plutôt « quel modèle d'exécution cette charge de travail requiert-elle ? »

Exemples résolus

Middleware d'authentification API HTTP — Une requête doit être interrompue par une erreur 401 avant l'exécution du contrôleur ; le résultat est un objet de réponse. Utilisez une structure en oignon avec $next. Flow ajouterait inutilement des paquets, des pilotes et await().

Importation nocturne de dix mille URL — chaque enregistrement est récupéré, normalisé et sauvegardé ; la latence de récupération est prépondérante ; les enregistrements sont indépendants. Une boucle synchrone foreach avec usleep() ou une requête HTTP bloquante entraînera une accumulation linéaire du temps d'attente. Un pipeline Flow avec AmpDriver et un client HTTP participant à la boucle d'événements est une solution envisageable, à condition de gérer la collecte des résultats et les erreurs pour chaque nœud.

Une seule commande Artisan transforme un DTO — trois fonctions pures en séquence. Une chaîne de méthodes privées ou un petit DTO suffisent. Ni Laravel Pipeline ni Flow ne justifient leur utilisation.

Cette démo appartient à la seconde catégorie, simplifiée : pas de HTTP, des temporisateurs artificiels, cinq paquets. C’est un outil pédagogique, pas un modèle de déploiement. Relancez demo:pipeline:compare sur votre machine avant de citer les temps d’exécution exacts dans la documentation destinée à la production.

Conclusion

Nous avons commencé par un mot polysémique. Pipeline décrit la composition. Il ne décrit pas, en soi, l'exécution.

Laravel Pipeline utilise une structure en oignon avec des fermetures imbriquées : une seule fonction $next explicite et valide, une pile d'appels synchrones et le résultat renvoyé par then(). Minimaliste et éprouvée, elle est parfaitement adaptée aux middlewares et à l'interception de commandes.

Darkwood Flow s'organise autour de nœuds liés : paquets d'instructions, planification par pilote, await() comme barrière, résultats collectés explicitement. Il intègre davantage de concepts car il vise un problème différent : le chevauchement des tâches entre paquets indépendants lorsque l'environnement d'exécution le permet.

Notre démonstration a rendu la différence visible :

  • 2,42 secondes séquentielles
  • 0,81 secondes de chevauchement
  • sortie identique
  • bûches entrelacées

Ces chiffres ne sont pas un argumentaire de vente. Ils illustrent de manière contrôlée une vérité plus profonde :

La distinction importante n'est pas entre Laravel et Flow, mais entre composition et exécution.

La prochaine fois que vous lirez le terme « pipeline » dans un fichier README, le titre d'un problème ou un document d'architecture, posez-vous immédiatement la question suivante : comment s'exécute-t-il ?

Si la réponse est « un $next à la fois », vous savez à quoi vous attendre. Si la réponse est « des paquets transitant par un pilote », vous savez qu'il y a autre chose : quelque chose qui peut entraîner des chevauchements d'attente et qui vous oblige à gérer vous-même le câblage des résultats.

Ce sont deux pipelines. Ce ne sont pas les mêmes machines.

Dépôt de démonstration

Cloner et exécuter :

composer install
php bin/console demo:pipeline:compare
php bin/phpunit
  • Source : laravel-pipeline
  • Diapositives : https://github.com/matyo91/slidewire

Connectez-vous pour réagir à cet article

🚀 1

Site

  • Plan du Site
  • Contact
  • Mentions légales

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, tous droits réservés