Darkwood Blog Blog
  • Articles
  • Watch
  • Releases
  • Creators
en
  • de
  • fr
Login
  • Blog
  • Articles
  • Watch
  • Releases
  • Creators

🔥 Profiling a Symfony flow with Blackfire

on July 5, 2026

Log in to add a reaction to this post

🚀 1

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:

  1. The Symfony worker - a manual while loop around each message
  2. The Flow driver - an internal execution loop within await() that drains packets (Ip) until exhaustion
  3. 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 while loop
  • 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 the Ips, dispatches AsyncEvent events, and resumes suspended fibers
  • AmpDriver - recursive loop via EventLoop::defer($loop) until packets are exhausted
  • ReactDriver, 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:

  1. The command runs in a loop and processes one message at a time.
  2. handleSignal() returns false on SIGUSR2: the worker continues during profiling
  3. The probe is stored in a static variable between two SIGUSR2
  4. 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:

  1. extract - instantiates a php-etl Pipeline, branches an ExtractorInterface which yields one AcceptanceResultBucket per line
  2. transform - TransformerInterface + FlushableInterface, generator with multiple yields: array_map + strtoupper + str_rot13 on each pass
  3. load - LoaderInterface + FlushableInterface, same generator pattern with additional transformations
  4. walk - $pipeline->walk() iterates through the results, writes var/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 yield of the TransformerInterface / LoaderInterface creates separate frames
  • array_map and str_rot13 in the transform step: identifiable CPU hotspot
  • file_put_contents in 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 - extracting Rows from 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 script
  • BuildPromptJob: string assembly, json_encode
  • FakeExecuteJob: preg_match_all on includes and routes, loop hash('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 yield or a JobInterface
  • 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

  • Kibono Flow
  • PHP Flow
  • VibePHP Flow

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

Log in to add a reaction to this post

🚀 1

Site

  • Sitemap
  • Contact
  • Legal mentions

Network

  • Hello
  • Blog
  • Apps
  • Photos

Social

Darkwood 2026, all rights reserved