Why Guessing Is Expensive
Most performance work starts with a hunch: "It must be the N+1 query" or "The cache is probably cold." Hunches waste hours. Profilers give you a call graph with wall-clock time, CPU time, memory delta, and I/O — so you fix the right thing first.
This article covers two complementary tools:
- Xdebug — free, always available, great for local deep-dives with a GUI like PHPStorm or KCachegrind.
- Blackfire — commercial, CI-friendly, built for continuous performance testing with assertions.
Xdebug: Callgrind Profiles Locally
Install Xdebug 3 and add to php.ini:
[xdebug]
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%p.%r
Trigger a profile for a single request by appending ?XDEBUG_PROFILE=1 or setting the cookie. For a CLI job:
XDEBUG_MODE=profile php artisan queue:work --once
Open the resulting cachegrind.out.* file in KCachegrind (Linux) or QCachegrind (macOS). Sort by Self Cost to find functions that consume time without delegating — these are your actual hot paths, not just callers.
Reading the Call Graph
A common surprise: PDOStatement::execute shows up with 40 % self cost because a Blade partial triggers 60 lazy-loaded relations. The call graph makes the chain obvious:
View::render
└─ BlogPost::author() ← repeated 50×
└─ PDOStatement::execute
Fix it with with('author') on the controller query, re-profile, confirm the cost drops.
Blackfire: Continuous Performance Assertions
Blackfire's killer feature is not the flame graph — it's assertions in CI. You write a .blackfire.yaml at the project root:
tests:
"Homepage loads fast":
path: /
assertions:
- "main.wall_time < 200ms"
- "metrics.sql.queries.count < 10"
- "metrics.http.requests.count == 1"
Push to a branch, run blackfire run php artisan blackfire:test (or the GitHub Action), and the build fails if a regression sneaks in. This is performance-as-code.
Profiling a Specific Artisan Command
blackfire run php artisan import:products --limit=100
Blackfire uploads the trace to its dashboard. Filter by exclusive time to find the single most expensive function call. A real example: json_decode inside a loop consuming 18 % of wall time because a 2 MB payload was decoded once per row instead of once per batch.
Instrumenting Custom Code
For long jobs, add manual probes so Blackfire can segment the timeline:
use Blackfire\Client;
use Blackfire\Profile\Configuration;
$blackfire = new Client();
$probe = $blackfire->createProbe((new Configuration())->setTitle('Batch import'));
foreach ($chunks as $chunk) {
$this->processChunk($chunk);
}
$blackfire->endProbe($probe);
This gives you a named segment in the timeline rather than one undifferentiated blob.
Combining Both Tools Effectively
| Scenario | Tool |
|---|---|
| Local deep-dive, no account needed | Xdebug + KCachegrind |
| CI regression gate | Blackfire assertions |
| Profiling a queue job in staging | Blackfire run |
| Memory leak hunt | Xdebug memory snapshots |
| Comparing two implementations | Blackfire comparison view |
A practical workflow: use Xdebug locally to understand what is slow, fix it, then write a Blackfire assertion so it never regresses.
Takeaways
- Never optimise without a profiler — wall-clock logs lie about where time is actually spent.
- Xdebug callgrind + KCachegrind is zero-cost and reveals the full PHP call graph.
- Blackfire assertions in CI make performance a first-class build constraint.
- Sort by exclusive/self time, not inclusive, to find the real culprit.
- Instrument long-running jobs with Blackfire probes to segment the timeline.
- Fix one bottleneck at a time and re-profile; compound fixes mask each other's impact.