🔥 Profiling a Symfony flow with Blackfire
on July 5, 2026
Blackfire is often associated with HTTP requests: profiling a page, reading the flamegraph, optimizing. But a large part of PHP processing doesn't go through the browser. These are long-running processes: Messenger consumers, sync workers, batch scripts scheduled by cron or Rundeck.
And loops aren't unique to Symfony. In darkwood/flow, they appear at several levels:
- The Symfony worker - a manual
whileloop around each message - The Flow driver - an internal execution loop within
await()that drains packets (Ip) until exhaustion YFlow- a recursive composition that re-executes a job as long as a condition demands it.
Three ways to express repetition. A single profiling question: what happens during an iteration?
// 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 can profile these processes. The real problem isn't the loop itself—it's what it encapsulates. If the entire business logic is contained within process(), the flamegraph is technically correct but practically unusable.
Profiling doesn't begin in Blackfire. It begins with how you structure the processing logic.
Flow makes the loop profilable by transforming each iteration into explicit execution steps.
I've prepared three Symfony 8 demos around darkwood/flow to illustrate this point. Each one exposes the same worker command, the same signal-based profiling mechanism, but a different processing scenario. The goal isn't to sell Flow; it's to show how naming the steps inside the loop makes profiling actionable.
The problem of opaque workers
A Symfony consumer is often:
- a handwritten
whileloop - a service call
- scattered logs
- and a
process()method that does everything
When profiling this worker, Blackfire sees PHP - not an architecture. We get a large block of CPU time, a few I/O calls buried within it, and the question "where to optimize?" remains open.
This isn't a flaw in Blackfire. The profiler records what's actually executed. If the code is monolithic, the profile will be too.
The pragmatic approach: structure the processing before profiling, so that each phase becomes a visible boundary - in the logs, and in the call frames.
The loop is not the enemy
The problem isn't the infinite loop. The problem is an infinite loop around an opaque process().
Three Repetition Patterns
| Model | Where | What he does |
|---|---|---|
while (true) |
Symfony command | Consumes messages up to SIGTERM |
Loop await() |
Drivers Flow | Drains pending IPs, dispatches jobs, resumes async |
YFlow / YJob |
Composition Flow | Re-invoke a job with new data (controlled recursion) |
The drivers: the loop is already in Flow
Each driver implements DriverInterface::await() with its own execution loop. The implementations available in Flow\Driver\ are:
FiberDriver- default driver for demos;do { … } while (countIps > 0)loop which pulls theIps, dispatchesAsyncEventevents, and resumes suspended fibersAmpDriver- recursive loop viaEventLoop::defer($loop)until packets are exhaustedReactDriver,SpatieDriver,SwooleDriver,ParallelDriver,TrueAsyncDriver- same principle, different async runtime
This is important for profiling: every call to $flow->await() goes through this inner loop. When our demos process a batch, the driver iterates through the flow steps until all the IPs are consumed. Blackfire sees both the Symfony worker loop and the driver loop—but more importantly, the jobs executed within them.
YFlow: Repetition without an explicit while
YFlow encapsulates a YJob – a controlled recursion mechanism inspired by the Y combinator. In practice, without going into the theory:
yield new YFlow(function ($loop) {
return function ($data) use ($loop) {
// traiter $data…
return $done ? $result : $loop($nextData); // ré-invoque le flow
};
});
A job can call itself back with new data. This isn't a linear pipeline; it's a reentrant execution. This is useful for iterative processing (pagination, retry, gradual collapse) without writing while loops in the business logic.
For profiling, the point remains the same: Blackfire observes what is executed with each re-summon. If the job body is opaque, the profile is also opaque.
What this changes for Blackfire
With SIGUSR2, we don't profile the command startup. We profile one or more iterations of the worker during the open window:
| Demo | One iteration = |
|---|---|
| Kibono Flow | A product batch (ProductBatch) traversing extract → transform → load → walk |
| PHP Flow | A batch commands (OrderBatch) traversing prepare → extract → transform → load |
| VibePHP Flow | A simulated request (VibeRequest) traversing resolve → read → prompt → execute → metrics |
The Symfony loop consumes the messages. The Flow driver orchestrates the steps. Blackfire must be triggered during this process - not at boot.
Profiling a long process with POSIX signals
Blackfire offers the PHP SDK (blackfire/php-sdk) for programmatically creating a probe. On an already running worker, you cannot click on a browser extension. However, you can send UNIX signals to the process.
Since Symfony 5.2, SignalableCommandInterface allows these signals to be intercepted cleanly:
| Signal | Role in our demos |
|---|---|
SIGUSR2 |
Starts or stops the Blackfire probe (toggle) |
SIGTERM / SIGINT |
Arms a shouldStop flag to exit between two iterations |
The pattern used in the three projects:
- The command runs in a loop and processes one message at a time.
handleSignal()returnsfalseonSIGUSR2: the worker continues during profiling- The probe is stored in a
staticvariable between twoSIGUSR2 - The graceful stop does not interrupt an ongoing iteration - it waits for the current message to finish
Blackfire profiles the PHP code running during the window opened by the two SIGUSR2 commands. In practice: launch the worker, wait for it to process at least one message, then send the first signal. The profile will capture the ongoing iterations—not the initialization of the Symfony container.
Structure the processing before profiling
darkwood/flow doesn't eliminate loops—it makes them readable. The Symfony worker loops over messages; the driver loops over IPs; YFlow can loop recursively. At each level, Flow offers the same solution: named steps instead of a monolithic process().
A process is described as a series of steps linked by packets (Ip), each step being a job or a 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 doesn't make Blackfire perform better. It doesn't create a magic layer in the flamegraph. When $flow->await() calls the FiberDriver loop, Blackfire sees that loop and the jobs it executes. If each step is a separate job, the flamegraph frames naturally separate.
In practice, each yield or each JobInterface class becomes a named frame in the profile. Console logs follow the same structure ([extract], [transform], etc.). You can correlate what you see in the terminal with what you see in Blackfire—even in the middle of a worker iteration.
For a 20-line script, it's disproportionate. For a multi-phase consumer, the effort/readability ratio is clear—even without profiling. Flow doesn't replace Messenger or an infrastructure orchestrator; it structures the code running within the worker.
Shared setup of the three demos
The three Symfony 8 projects share the same infrastructure:
| Element | Detail |
|---|---|
| Command | app:flow:profile-demo |
| Default mode | Consumer long (while (!$shouldStop)) |
| Options | --limit N (quit after N messages), --sleep (pause if queue is empty) |
| Profiling | BlackfireSignalHandler + SignalableCommandInterface |
| SDK | blackfire/php-sdk to require-dev |
| Credentials | BLACKFIRE_CLIENT_ID and BLACKFIRE_CLIENT_TOKEN in .env.local |
Run a quick test
bin/console app:flow:profile-demo --limit=1 -vv
Profiling a worker in progress
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
Each iteration also logs duration and memory peak on the console side - useful for cross-referencing with the flamegraph.
Case 1 - Kibono Flow: Making an ETL pipeline visible
Demo: Kibono Flow
This demo is inspired by the Kiboko/Gyroscops ecosystem and php-etl. The idea: to wrap a Kiboko\Component\Pipeline\Pipeline in darkwood/flow, without rewriting the existing ETL.
Technical Structure
Each queue message (data/products-queue.jsonl) is a ProductBatch - a JSON array of rows produced row by row. The flow chains four steps via ProductSyncFlowFactory:
- extract - instantiates a php-etl
Pipeline, branches anExtractorInterfacewhich yields oneAcceptanceResultBucketper line - transform -
TransformerInterface+FlushableInterface, generator with multipleyields:array_map+strtoupper+str_rot13on each pass - load -
LoaderInterface+FlushableInterface, same generator pattern with additional transformations - walk -
$pipeline->walk()iterates through the results, writesvar/output/products-*.json, and displays the summary
The FlowFactory passes the Pipeline object from one stage to another: each yield in the factory corresponds to a distinct phase of the processing.
bin/console app:flow:profile-demo --limit=1 -vv
What Blackfire Makes Visible
- Generator boundaries in transform and load: each
yieldof theTransformerInterface/LoaderInterfacecreates separate frames array_mapandstr_rot13in the transform step: identifiable CPU hotspotfile_put_contentsin walk: I/O time distinct from transformation time
The interest here is to profile a "real" ETL (php-etl generators, buckets, flush) - not an artificial sleep() - and to see the flamegraph follow the structure of the pipeline.
Case 2 - PHP Flow: two layers, ETL + orchestration
Demo: PHP Flow
ETL Reference: Flow PHP
This demo is not a generic ETL invented for this article. It is inspired by the ETL abstractions of Flow PHP - extractor, transformer, loader, pipeline - adapted in a simplified version in a Symfony 8 worker orchestrated by darkwood/flow.
The idea: to separate ETL abstraction (how data goes through extract → transform → load) from execution orchestration (how a Symfony worker chains these steps in a consumer loop).
In this example, Flow is not just a simple pipeline runner. It becomes an orchestration layer on top of an ETL abstraction, making transformation hotspots easier to isolate in Blackfire.
Layer 1 - ETL Abstraction
Inspired by Flow PHP, without copying the code:
| PHP Flow Concept | Demo adaptation |
|---|---|
Extractor |
App\Etl\Extractor - OrderBatchExtractor yields Rows |
Transform |
App\Etl\Transformer - NormalizeOrderTransformer |
Loader |
App\Etl\Loader - JsonOrderLoader |
Pipeline::process() |
App\Etl\Pipeline - chain extract → transform → load |
Flow + DataFrame |
App\Etl\OrderEtlFlow - configures the pipeline for orders |
Data flows in the form of Row / Rows (a simplified model of PHP Flow types). The FlowContext contains the current batch file and the output directory – a minimal equivalent of the PHP Flow execution context.
Pipeline::process() follows the same logic: the extractor yields batches, each batch goes through the transformer and then the loader.
Layer 2 - Darkwood/flow orchestration
OrderEtlFlowFactory uses Flow\FlowFactory (darkwood) to chain jobs that call each ETL step separately:
| Job darkwood/flow | Calls on the ETL side |
|---|---|
PrepareEtlExecutionJob |
Instantiates OrderEtlFlow + FlowContext |
RunEtlExtractJob |
Pipeline::extract() |
RunEtlTransformJob |
Pipeline::transform() |
RunEtlLoadJob |
Pipeline::load() |
Each job is a separate frame in Blackfire. Each ETL call inside (NormalizeOrderTransformer::transform, etc.) is a named subframe.
Data: data/orders.csv - 100 lines split into 10 batches of 10 by InMemoryQueue.
Output: var/output/orders-batch-*.json
bin/console app:flow:profile-demo --limit=1 -vv
Why this abstraction helps profiling
Without the ETL layer, we would have three Symfony jobs that manipulate arrays - it profiles, but it does not model a reusable pipeline.
Without darkwood/flow, we would have a monolithic OrderEtlFlow::run() - a single frame "run" in Blackfire, even if the interior is structured.
The two layers together:
- The ETL layer defines where the business boundaries are (extract / transform / load)
- darkwood/flow defines where the execution boundaries are (one job consumer per step)
What Blackfire Makes Visible
- Layer 2:
RunEtlTransformJob- orchestration frame - Layer 1:
NormalizeOrderTransformer::transform- hotspot CPU (SHA-256 ×1000 per line) - Layer 1:
OrderBatchExtractor::extract- extractingRowsfrom the batch - Layer 1:
JsonOrderLoader::load-json_encode+file_put_contents
The difference with a monolithic Symfony command: the flamegraph distinguishes the orchestration (darkwood jobs) from the ETL process (interfaces App\Etl). The transform can be optimized without affecting the worker, and vice versa.
Case 3 - VibePHP Flow: measuring an application pipeline
Demo: VibePHP Flow
Reference: VibePHP
VibePHP is a project where PHP is not executed: a runtime reads the source code and produces an imagined HTTP response. Profiling this with a true AI API would be non-deterministic.
This demo adapts ideas from VibePHP into a Symfony Flow pipeline, focusing on source interpretation and observable execution steps.
The demo showcases the structure of the Flow job query pipeline, with a reproducible fake runtime.
Technical Structure
Each entry in data/requests.jsonl ({ "method", "path" }) becomes a VibeRequest passed in an IP. VibeRequestFlowFactory chains five jobs:
| Job | Role |
|---|---|
ResolveScriptJob |
Maps the path to a file in vibe/ (candidates: $path, $path.php, $path/index.php, index.php) |
ReadSourceJob |
file_get_contents on the resolved script |
BuildPromptJob |
Assembles a JSON context (method, uri, script, source) |
FakeExecuteJob |
Regex on require/include, detection of preg_match routes, construction of dummy HTML or JSON, hash loop ×500 |
LogMetricsJob |
Console table: method, path, status, duration, memory peak, includes detected |
No Laravel, no AI calls. The cost is simulated but stable from one execution to the next.
bin/console app:flow:profile-demo --limit=1 -vv
What Blackfire Makes Visible
ReadSourceJob:file_get_contents- I/O reading PHP scriptBuildPromptJob: string assembly,json_encodeFakeExecuteJob:preg_match_allon includes and routes, loophash('sha256')- the CPU cost of the simulated "execution"
Without Flow, everything would be contained in a single handleRequest() method. With Flow, each phase has a name, a logged duration, and a frame in the profile. This is the most compelling demo for moving from an opaque command to an inspectable pipeline.
What Blackfire shows - and what it doesn't show
Blackfire is good for:
- CPU time per function
- the allocated memory
- I/O calls (files, network)
- the depth of appeal and hot dependencies
Blackfire doesn't say:
- if the transform step is poorly designed business logic
- if a cache is needed in a certain place
- if Flow is "faster" than another approach
It's up to us to establish the link between flamegraph and architectural intention. The three demos are designed to make this link clear:
| Demo | Question that the profile helps to answer |
|---|---|
| Kibono Flow | Where does time go in a generator-based ETL? |
| PHP Flow | Which ETL stage to optimize - orchestration or transform? |
| VibePHP Flow | Reading, serializing, or simulated execution? |
What Flow offers compared to a classic Symfony command
A Symfony command can implement SignalableCommandInterface and be profiled with SIGUSR2 - Flow is not a prerequisite for Blackfire.
What Flow adds is a slicing contract:
- each step = a
yieldor aJobInterface - the data passes through a typed
IP await()marks the end of a complete process
| Opaque command | Command + Flow |
|---|---|
| A large block in the flamegraph | Frames per orchestration stage |
| Handcrafted logs | Sections [extract], [transform], etc. |
Worker loop + opaque process() |
Worker loop + driver loop + named steps |
| ETL and worker layer combined | ETL layer (App\Etl) + orchestration layer (darkwood/flow) separated |
Flow does not replace Messenger, Scheduler, or an infrastructure orchestrator. It is a readability tool for the code running within the worker.
Conclusion
Profiling a Symfony flow isn't about plugging Blackfire into a while loop and hoping for an insight. Loops exist at several levels—Symfony worker, Flow driver, possibly YFlow—and that's not a problem in itself.
The problem is an opaque iteration. Flow makes the loop profilable by transforming each iteration into explicit execution steps - visible in the logs, identifiable in the flamegraph.
The three demos – Kibono Flow, PHP Flow orchestrated by darkwood/flow, and VibePHP Flow – demonstrate three breakdowns of the same process: read, transform, produce. Blackfire profiles the PHP that executes during one or more iterations; Flow helps ensure that this PHP is not an anonymous block.
Profiling doesn't begin in Blackfire. It begins when we decide that process() is no longer a black box.
Sources and further reading
Blackfire Inspiration - profiling a consumer with signals
- Profiling a Consumer with Blackfire - JoliCode
Demo Repositories
Related Projects
- darkwood/flow - Flow component (drivers,
YFlow, orchestration) - Flow PHP - Reference ETL architecture for the PHP Flow demo
- VibePHP - reference project for the VibePHP Flow demo
- php-etl/pipeline - Kiboko documentation used in Kibono Flow
Summary Orders
After cloning a demo repository:
# 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