🔥 Profiler un flow Symfony avec Blackfire
le 5 juillet 2026
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 :
- Le worker Symfony - une boucle
whilemanuelle autour de chaque message - Le driver Flow - une boucle d'exécution interne dans
await()qui draine les paquets (Ip) jusqu'à épuisement 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 ; boucledo { … } while (countIps > 0)qui pull lesIp, dispatch les événementsAsyncEvent, et reprend les fibers suspenduesAmpDriver- boucle récursive viaEventLoop::defer($loop)jusqu'à épuisement des paquetsReactDriver,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 :
- La commande tourne en boucle et traite un message à la fois
handleSignal()retournefalsesurSIGUSR2: le worker continue pendant le profiling- La sonde est conservée dans une variable
staticentre deuxSIGUSR2 - 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 :
- extract - instancie un
Pipelinephp-etl, branche unExtractorInterfacequi yield unAcceptanceResultBucketpar ligne - transform -
TransformerInterface+FlushableInterface, générateur avec plusieursyield:array_map+strtoupper+str_rot13sur chaque passage - load -
LoaderInterface+FlushableInterface, même pattern générateur avec transformations supplémentaires - walk -
$pipeline->walk()itère les résultats, écritvar/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
yieldduTransformerInterface/LoaderInterfacecrée des frames séparés array_mapetstr_rot13dans l'étape transform : hotspot CPU identifiablefile_put_contentsdans 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 desRowsdepuis 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 PHPBuildPromptJob: assemblage de chaînes,json_encodeFakeExecuteJob:preg_match_allsur les includes et routes, bouclehash('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
yieldou unJobInterface - les données passent par un
Iptypé 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
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