⚖️ Laravel Pipeline et Darkwood Flow : deux modèles d’exécution pour composer des étapes PHP
le 11 juillet 2026
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 :
- Étapes ordonnées — une séquence définie de transformations ou d'intercepteurs
- Une valeur en flux continu — quelque chose qui passe d'une étape à l'autre (une requête, une commande, un paquet de données)
- 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 :
- Une fonction de rappel
$nextexplicite — 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 :
- 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 :
array_reverse($this->pipes())— la liste des pipes est inversée avant la réduction$this->carry()— un réducteur qui enroule chaque pipe autour de la pile accumulée$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
$nextrepré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 :
- Exécution séquentielle bloquante — une seule tâche s'exécute ; toutes les autres attendent.
- Concurrence coopérative — plusieurs tâches logiques s'entrelacent lorsqu'elles cèdent explicitement la priorité.
- 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
FetchJobappelait 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
- L'exécution séquentielle bloquante accumule le temps d'attente de manière linéaire.
- Les attentes indépendantes planifiées par la boucle d'événements se chevauchent sur un seul thread.
- 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.
- Les deux implémentations produisent des hachages et des fichiers identiques (vérifié par PHPUnit).
Ce que les chiffres ne démontrent pas
- Ce flux est généralement « trois fois plus rapide ».
- Que le pipeline Laravel est lent ou mal conçu
- Le problème du protocole HTTP asynchrone est « résolu ».
- 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. - 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).
- 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