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

🔥 Profiler un flow Symfony avec Blackfire

le 5 juillet 2026

Connectez-vous pour réagir à cet article

🚀 1

Blackfire est souvent associé aux requêtes HTTP : on profile une page, on lit le flamegraph, on optimise. Mais une grande partie du traitement PHP ne passe pas par le navigateur. Ce sont des processus longs : consumers Messenger, workers de sync, scripts batch planifiés par cron ou Rundeck.

Et les boucles ne sont pas l'apanage de Symfony seul. Dans darkwood/flow, elles apparaissent à plusieurs niveaux :

  1. Le worker Symfony - une boucle while manuelle autour de chaque message
  2. Le driver Flow - une boucle d'exécution interne dans await() qui draine les paquets (Ip) jusqu'à épuisement
  3. YFlow - une composition récursive qui ré-exécute un job tant qu'une condition le demande

Trois façons d'exprimer la répétition. Une seule question de profiling : que se passe-t-il pendant une itération ?

// Worker Symfony classique
while (true) {
    $message = $queue->consume();
    if (!$message) {
        sleep(1);
        continue;
    }
    $this->process($message);  // ← boîte noire
}
// FiberDriver::await() - simplifié
do {
    // pull des Ip, dispatch async, reprise des fibers…
} while ($this->countIps($stream['dispatchers']) > 0 or count($this->ticks) > 0);

Blackfire peut profiler ces processus. Le vrai problème n'est pas la boucle - c'est ce qu'elle encapsule. Si tout le métier tient dans process(), le flamegraph est techniquement correct et pratiquement inutilisable.

Le profiling ne commence pas dans Blackfire. Il commence dans la façon dont on structure la logique de traitement.

Flow rend la boucle profileable en transformant chaque itération en étapes d'exécution explicites.

J'ai préparé trois démos Symfony 8 autour de darkwood/flow pour illustrer ce point. Chacune expose la même commande worker, le même mécanisme de profiling par signaux, mais un cas de traitement différent. L'objectif n'est pas de vendre Flow : c'est de montrer comment nommer les étapes à l'intérieur de la boucle rend le profiling actionnable.

Le problème des workers opaques

Un consumer Symfony, c'est souvent :

  • une boucle while écrite à la main
  • un appel de service
  • des logs épars
  • et une méthode process() qui fait tout

Quand on profile ce worker, Blackfire voit du PHP - pas une architecture. On obtient un gros bloc de temps CPU, quelques appels I/O noyés dedans, et la question « où optimiser ? » reste ouverte.

Ce n'est pas un défaut de Blackfire. Le profiler enregistre ce qui s'exécute réellement. Si le code est monolithique, le profil l'est aussi.

La piste pragmatique : structurer le traitement avant de profiler, de façon à ce que chaque phase devienne une frontière visible - dans les logs, et dans les frames d'appel.

La boucle n'est pas l'ennemi

Le problème n'est pas la boucle infinie. Le problème, c'est une boucle infinie autour d'un process() opaque.

Trois modèles de répétition

Modèle Où Ce qu'il fait
while (true) Commande Symfony Consomme des messages jusqu'à SIGTERM
Boucle await() Drivers Flow Draine les Ip en attente, dispatch les jobs, reprend l'async
YFlow / YJob Composition Flow Ré-invoque un job avec de nouvelles données (récursion contrôlée)

Les drivers : la boucle est déjà dans Flow

Chaque driver implémente DriverInterface::await() avec sa propre boucle d'exécution. Les implémentations disponibles dans Flow\Driver\ :

  • FiberDriver - driver par défaut des démos ; boucle do { … } while (countIps > 0) qui pull les Ip, dispatch les événements AsyncEvent, et reprend les fibers suspendues
  • AmpDriver - boucle récursive via EventLoop::defer($loop) jusqu'à épuisement des paquets
  • ReactDriver, SpatieDriver, SwooleDriver, ParallelDriver, TrueAsyncDriver - même principe, runtime async différent

Ce n'est pas anodin pour le profiling : chaque appel à $flow->await() traverse cette boucle interne. Quand nos démos traitent un batch, le driver itère sur les étapes du flow jusqu'à ce que tous les Ip soient consommés. Blackfire voit à la fois la boucle du worker Symfony et la boucle du driver - mais surtout les jobs exécutés à l'intérieur.

YFlow : la répétition sans while explicite

YFlow encapsule un YJob - un mécanisme de récursion contrôlée inspiré du Y-combinator. En pratique, sans entrer dans la théorie :

yield new YFlow(function ($loop) {
    return function ($data) use ($loop) {
        // traiter $data…
        return $done ? $result : $loop($nextData);  // ré-invoque le flow
    };
});

Un job peut se rappeler lui-même avec de nouvelles données. Ce n'est pas un pipeline linéaire : c'est une exécution ré-entrante. Utile pour des traitements itératifs (pagination, retry, collapse progressif) sans écrire while dans le métier.

Pour le profiling, le point reste le même : Blackfire observe ce qui s'exécute à chaque ré-invocaton. Si le corps du job est opaque, le profil l'est aussi.

Ce que ça change pour Blackfire

Avec SIGUSR2, on ne profile pas le démarrage de la commande. On profile une ou plusieurs itérations du worker pendant la fenêtre ouverte :

Démo Une itération =
Kibono Flow Un batch produit (ProductBatch) traversant extract → transform → load → walk
PHP Flow Un batch commandes (OrderBatch) traversant prepare → extract → transform → load
VibePHP Flow Une requête simulée (VibeRequest) traversant resolve → read → prompt → execute → metrics

La boucle Symfony consomme les messages. Le driver Flow orchestre les étapes. Blackfire doit être déclenché pendant ce travail - pas au boot.

Profiler un processus long avec des signaux POSIX

Blackfire propose le SDK PHP (blackfire/php-sdk) pour créer une sonde programmatiquement. Sur un worker déjà lancé, on ne peut pas cliquer sur une extension navigateur. En revanche, on peut envoyer des signaux UNIX au processus.

Depuis Symfony 5.2, SignalableCommandInterface permet d'intercepter ces signaux proprement :

Signal Rôle dans nos démos
SIGUSR2 Démarre ou arrête la sonde Blackfire (toggle)
SIGTERM / SIGINT Arme un flag shouldStop pour sortir entre deux itérations

Le pattern retenu dans les trois projets :

  1. La commande tourne en boucle et traite un message à la fois
  2. handleSignal() retourne false sur SIGUSR2 : le worker continue pendant le profiling
  3. La sonde est conservée dans une variable static entre deux SIGUSR2
  4. L'arrêt gracieux ne coupe pas une itération en cours - il attend la fin du message courant

Blackfire profile le code PHP qui tourne pendant la fenêtre ouverte par les deux SIGUSR2. En pratique : lancer le worker, attendre qu'il traite au moins un message, puis envoyer le premier signal. Le profil capturera les itérations en cours - pas l'initialisation du container Symfony.

Structurer le traitement avant de profiler

darkwood/flow ne supprime pas les boucles - il les rend lisibles. Le worker Symfony boucle sur les messages ; le driver boucle sur les Ip ; YFlow peut boucler par récursion. À chaque niveau, Flow propose le même remède : des étapes nommées au lieu d'un process() monolithique.

On décrit un traitement comme une suite d'étapes reliées par des paquets (Ip), chaque étape étant un job ou une closure :

$flow = $flowFactory->create(static function () {
    yield new PrepareEtlExecutionJob($io, $outputDir);
    yield new RunEtlExtractJob($io);
    yield new RunEtlTransformJob($io);
    yield new RunEtlLoadJob($io);
});

$flow(new Ip($batch));
$flow->await();

Flow ne rend pas Blackfire plus performant. Il ne crée pas une couche magique dans le flamegraph. Quand $flow->await() appelle la boucle du FiberDriver, Blackfire voit cette boucle et les jobs qu'elle exécute. Si chaque étape est un job distinct, les frames du flamegraph se séparent naturellement.

En pratique, chaque yield ou chaque classe JobInterface devient un frame nommé dans le profil. Les logs console suivent la même structure ([extract], [transform], etc.). On peut corréler ce qu'on voit dans le terminal avec ce qu'on voit dans Blackfire - y compris au milieu d'une itération du worker.

Pour un script de 20 lignes, c'est disproportionné. Pour un consumer multi-phases, le rapport effort/lisibilité est net - y compris sans profiler. Flow ne remplace pas Messenger ni un orchestrateur infra : il structure le code qui tourne dans le worker.

Setup partagé des trois démos

Les trois projets Symfony 8 partagent la même infrastructure :

Élément Détail
Commande app:flow:profile-demo
Mode par défaut Consumer long (while (!$shouldStop))
Options --limit N (quitter après N messages), --sleep (pause si file vide)
Profiling BlackfireSignalHandler + SignalableCommandInterface
SDK blackfire/php-sdk en require-dev
Credentials BLACKFIRE_CLIENT_ID et BLACKFIRE_CLIENT_TOKEN dans .env.local

Lancer un test rapide

bin/console app:flow:profile-demo --limit=1 -vv

Profiler un worker en cours

Terminal 1 :

bin/console app:flow:profile-demo -vv

Terminal 2 :

ps aux | grep profile-demo
kill -USR2 <pid>   # démarrer le profiling
# laisser tourner quelques itérations…
kill -USR2 <pid>   # arrêter → URL du profil dans les logs

Chaque itération logue aussi durée et mémoire pic côté console - utile pour croiser avec le flamegraph.

Cas 1 - Kibono Flow : rendre visible un pipeline ETL

Démo : Kibono Flow

Cette démo s'inspire de l'écosystème Kiboko / Gyroscops et php-etl. L'idée : envelopper un Kiboko\Component\Pipeline\Pipeline dans darkwood/flow, sans réécrire l'ETL existant.

Structure technique

Chaque message de file (data/products-queue.jsonl) est un ProductBatch - un tableau JSON de lignes produit par ligne. Le flow enchaîne quatre étapes via ProductSyncFlowFactory :

  1. extract - instancie un Pipeline php-etl, branche un ExtractorInterface qui yield un AcceptanceResultBucket par ligne
  2. transform - TransformerInterface + FlushableInterface, générateur avec plusieurs yield : array_map + strtoupper + str_rot13 sur chaque passage
  3. load - LoaderInterface + FlushableInterface, même pattern générateur avec transformations supplémentaires
  4. walk - $pipeline->walk() itère les résultats, écrit var/output/products-*.json, affiche le résumé

Le FlowFactory passe l'objet Pipeline d'une étape à l'autre : chaque yield dans la factory correspond à une phase distincte du traitement.

bin/console app:flow:profile-demo --limit=1 -vv

Ce que Blackfire rend visible

  • Les frontières des générateurs dans transform et load : chaque yield du TransformerInterface / LoaderInterface crée des frames séparés
  • array_map et str_rot13 dans l'étape transform : hotspot CPU identifiable
  • file_put_contents dans walk : temps I/O distinct du temps de transformation

L'intérêt ici est de profiler un ETL « réel » (générateurs php-etl, buckets, flush) - pas un sleep() artificiel - et de voir le flamegraph suivre la structure du pipeline.

Cas 2 - PHP Flow : deux couches, ETL + orchestration

Démo : PHP Flow
Référence ETL : Flow PHP

Cette démo n'est pas un ETL générique inventé pour l'article. Elle s'inspire des abstractions ETL de Flow PHP - extractor, transformer, loader, pipeline - adaptées en version simplifiée dans un worker Symfony 8 orchestré par darkwood/flow.

L'idée : séparer l'abstraction ETL (comment les données traversent extract → transform → load) de l'orchestration d'exécution (comment un worker Symfony enchaîne ces étapes dans une boucle consumer).

Dans cet exemple, Flow n'est pas qu'un simple runner de pipeline. Il devient une couche d'orchestration au-dessus d'une abstraction ETL, ce qui rend les hotspots de transformation plus faciles à isoler dans Blackfire.

Couche 1 - Abstraction ETL

Inspirée de Flow PHP, sans copier le code :

Concept Flow PHP Adaptation démo
Extractor App\Etl\Extractor - OrderBatchExtractor yield des Rows
Transformer App\Etl\Transformer - NormalizeOrderTransformer
Loader App\Etl\Loader - JsonOrderLoader
Pipeline::process() App\Etl\Pipeline - enchaîne extract → transform → load
Flow + DataFrame App\Etl\OrderEtlFlow - configure le pipeline pour les commandes

Les données circulent sous forme de Row / Rows (modèle simplifié des types Flow PHP). Le FlowContext porte le batch courant et le répertoire de sortie - équivalent minimal du contexte d'exécution Flow PHP.

Pipeline::process() suit la même logique : l'extracteur yield des batches, chaque batch passe par le transformeur puis le loader.

Couche 2 - Orchestration darkwood/flow

OrderEtlFlowFactory utilise Flow\FlowFactory (darkwood) pour enchaîner des jobs qui appellent chaque étape ETL séparément :

Job darkwood/flow Appelle côté ETL
PrepareEtlExecutionJob Instancie OrderEtlFlow + FlowContext
RunEtlExtractJob Pipeline::extract()
RunEtlTransformJob Pipeline::transform()
RunEtlLoadJob Pipeline::load()

Chaque job est un frame distinct dans Blackfire. Chaque appel ETL à l'intérieur (NormalizeOrderTransformer::transform, etc.) est un sous-frame nommé.

Données : data/orders.csv - 100 lignes découpées en 10 batches de 10 par InMemoryQueue.
Sortie : var/output/orders-batch-*.json

bin/console app:flow:profile-demo --limit=1 -vv

Pourquoi cette abstraction aide le profiling

Sans la couche ETL, on aurait trois jobs Symfony qui manipulent des tableaux - ça profile, mais ça ne modélise pas un pipeline réutilisable.

Sans darkwood/flow, on aurait un OrderEtlFlow::run() monolithique - un seul frame « run » dans Blackfire, même si l'intérieur est structuré.

Les deux couches ensemble :

  • la couche ETL définit où sont les frontières métier (extract / transform / load)
  • darkwood/flow définit où sont les frontières d'exécution (un job consumer par étape)

Ce que Blackfire rend visible

  • Couche 2 : RunEtlTransformJob - frame d'orchestration
  • Couche 1 : NormalizeOrderTransformer::transform - hotspot CPU (SHA-256 ×1000 par ligne)
  • Couche 1 : OrderBatchExtractor::extract - extraction des Rows depuis le batch
  • Couche 1 : JsonOrderLoader::load - json_encode + file_put_contents

La différence avec une commande Symfony monolithique : le flamegraph distingue l'orchestration (jobs darkwood) du métier ETL (interfaces App\Etl). On peut optimiser le transform sans toucher au worker, et inversement.

Cas 3 - VibePHP Flow : mesurer un pipeline applicatif

Démo : VibePHP Flow
Référence : VibePHP

VibePHP est un projet où le PHP n'est pas exécuté : un runtime lit le source et produit une réponse HTTP imaginée. Profiler ça avec une vraie API IA serait non déterministe.

Cette démo adapte des idées de VibePHP dans un pipeline Symfony Flow, en se concentrant sur l'interprétation du source et des étapes d'exécution observables.

La démo porte la structure du pipeline de requête en jobs Flow, avec un fake runtime reproductible.

Structure technique

Chaque entrée de data/requests.jsonl ({ "method", "path" }) devient un VibeRequest passé dans un Ip. VibeRequestFlowFactory enchaîne cinq jobs :

Job Rôle
ResolveScriptJob Mappe le path vers un fichier dans vibe/ (candidats : $path, $path.php, $path/index.php, index.php)
ReadSourceJob file_get_contents sur le script résolu
BuildPromptJob Assemble un contexte JSON (method, uri, script, source)
FakeExecuteJob Regex sur require/include, détection de routes preg_match, construction HTML ou JSON factice, boucle hash ×500
LogMetricsJob Tableau console : method, path, status, durée, mémoire pic, includes détectés

Pas de Laravel, pas d'appel IA. Le coût est simulé mais stable d'une exécution à l'autre.

bin/console app:flow:profile-demo --limit=1 -vv

Ce que Blackfire rend visible

  • ReadSourceJob : file_get_contents - I/O lecture du script PHP
  • BuildPromptJob : assemblage de chaînes, json_encode
  • FakeExecuteJob : preg_match_all sur les includes et routes, boucle hash('sha256') - le coût CPU de l'« exécution » simulée

Sans Flow, tout tiendrait dans une méthode handleRequest(). Avec Flow, chaque phase a un nom, une durée loguée, et un frame dans le profil. C'est la démo la plus parlante pour passer d'une commande opaque à un pipeline inspectable.

Ce que Blackfire montre - et ce qu'il ne montre pas

Blackfire est bon pour :

  • le temps CPU par fonction
  • la mémoire allouée
  • les appels I/O (fichiers, réseau)
  • la profondeur d'appel et les dépendances chaudes

Blackfire ne dit pas :

  • si l'étape transform est mal conçue métier
  • s'il faut un cache à tel endroit
  • si Flow est « plus rapide » qu'une autre approche

Le lien entre flamegraph et intention architecturale, c'est à nous de le faire. Les trois démos sont pensées pour que ce lien soit évident :

Démo Question que le profil aide à trancher
Kibono Flow Où va le temps dans un ETL à générateurs ?
PHP Flow Quel stage ETL optimiser - orchestration ou transform ?
VibePHP Flow Lecture, sérialisation ou exécution simulée ?

Ce que Flow apporte par rapport à une commande Symfony classique

Une commande Symfony peut implémenter SignalableCommandInterface et se faire profiler avec SIGUSR2 - Flow n'est pas un prérequis pour Blackfire.

Ce que Flow ajoute, c'est un contrat de découpage :

  • chaque étape = un yield ou un JobInterface
  • les données passent par un Ip typé
  • await() marque la fin d'un traitement complet
Commande opaque Commande + Flow
Un gros bloc dans le flamegraph Des frames par étape d'orchestration
Logs artisanaux Sections [extract], [transform], etc.
Boucle worker + process() opaque Boucle worker + boucle driver + étapes nommées
ETL et worker mélangés Couche ETL (App\Etl) + couche orchestration (darkwood/flow) séparées

Flow ne remplace pas Messenger, Scheduler ou un orchestrateur infra. C'est un outil de lisibilité pour le code qui tourne dans le worker.

Conclusion

Profiler un flow Symfony, ce n'est pas brancher Blackfire sur une boucle while et espérer un insight. Les boucles existent à plusieurs niveaux - worker Symfony, driver Flow, éventuellement YFlow - et ce n'est pas un problème en soi.

Le problème, c'est une itération opaque. Flow rend la boucle profileable en transformant chaque itération en étapes d'exécution explicites - visibles dans les logs, identifiables dans le flamegraph.

Les trois démos - Kibono Flow, PHP Flow orchestré par darkwood/flow, VibePHP Flow - montrent trois découpages du même schéma : lire, transformer, produire. Blackfire profile le PHP qui s'exécute pendant une ou plusieurs itérations ; Flow aide à ce que ce PHP ne soit pas un bloc anonyme.

Le profiling ne commence pas dans Blackfire. Il commence quand on décide que process() n'est plus une boîte noire.

Sources et pour aller plus loin

Inspiration Blackfire - profiling d'un consumer avec signaux

  • Profiler un consumer avec Blackfire - JoliCode

Dépôts des démos

  • Kibono Flow
  • PHP Flow
  • VibePHP Flow

Projets liés

  • darkwood/flow - composant Flow (drivers, YFlow, orchestration)
  • Flow PHP - architecture ETL de référence pour la démo PHP Flow
  • VibePHP - projet de référence pour la démo VibePHP Flow
  • php-etl / pipeline - documentation Kiboko utilisée dans Kibono Flow

Commandes récapitulatives

Après clonage d'un dépôt de démo :

# Kibono Flow - ETL php-etl dans darkwood/flow
git clone https://github.com/matyo91/kibono-flow.git && cd kibono-flow
bin/console app:flow:profile-demo --limit=1 -vv

# PHP Flow - ETL Flow PHP + orchestration darkwood/flow
git clone https://github.com/matyo91/php-flow.git && cd php-flow
bin/console app:flow:profile-demo --limit=1 -vv

# VibePHP Flow - pipeline de requête
git clone https://github.com/matyo91/vibephp-flow.git && cd vibephp-flow
bin/console app:flow:profile-demo --limit=1 -vv

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