Stop Guessing, Start Measuring
Every senior engineer has inherited a slow Laravel app where the previous team "optimised" it by adding Redis caches in random places. Real performance work starts with a profiler, not intuition. This article covers two complementary tools: Xdebug for local deep-dives and Blackfire for continuous, low-overhead profiling in staging and CI.
Xdebug: Local Call-Graph Profiling
Xdebug's profiler writes cachegrind files you can open in QCacheGrind (macOS/Linux) or WinCacheGrind. Enable it only when needed — it adds significant overhead.
; php.ini (local only)
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%p.%r
xdebug.start_with_request=trigger
Trigger a profile for a single request without slowing everything else:
curl -X GET 'https://app.test/api/reports/monthly' \
-H 'X-Xdebug-Profile: 1'
Or use the browser extension Xdebug Helper and click the profile icon.
Once you open the cachegrind file in QCacheGrind, sort by Self Cost (time spent in the function itself, not its callees). A common surprise: json_encode on a 10 000-row Eloquent collection sitting at 40 % of wall time because someone forgot ->only(['id','name']) on the resource.
What to Look For
- Functions with high inclusive cost but low self cost → the real work is in their children; drill down.
- Repeated calls to the same function thousands of times → classic N+1 hiding behind a helper.
PDOStatement::executeappearing hundreds of times → confirm with Laravel Debugbar or Telescope.
Blackfire: Continuous, Low-Overhead Profiling
Blackfire instruments PHP at the C extension level and samples, not traces, so overhead is roughly 1–3 %. It is safe to run in staging and can be gated in CI.
Install the Agent and Probe
# On a Debian/Ubuntu staging server
curl -1sLf 'https://packages.blackfire.io/gpg.key' | gpg --dearmor > /usr/share/keyrings/blackfire.gpg
echo "deb [signed-by=/usr/share/keyrings/blackfire.gpg] http://packages.blackfire.io/debian any main" \
> /etc/apt/sources.list.d/blackfire.list
apt-get update && apt-get install blackfire blackfire-php
Add credentials to your environment:
blackfire agent:config --server-id=YOUR_ID --server-token=YOUR_TOKEN
Profile a Laravel Artisan Command
blackfire run php artisan reports:generate --month=2025-05
Blackfire returns a URL with a flame graph and a call graph. The hot path is highlighted automatically.
Profile an HTTP Endpoint
blackfire curl https://staging.app.test/api/reports/monthly \
-H 'Authorization: Bearer TOKEN'
Writing Blackfire Assertions in CI
Blackfire supports .blackfire.yaml for performance budgets:
# .blackfire.yaml
tests:
"Monthly report endpoint":
path: /api/reports/monthly
assertions:
- "main.wall_time < 300ms"
- "main.peak_memory < 32mb"
- "metrics.sql.queries.count < 10"
This fails the pipeline if the endpoint regresses. The SQL query count assertion is the most valuable — it catches N+1 regressions before they reach production.
A Real-World Workflow
- Reproduce the slow request in a local or staging environment.
- Xdebug profile to get the full call graph with exact timings.
- Fix the obvious wins: eager-load relations, add missing indexes, reduce serialisation payload.
- Blackfire profile before and after to compare call graphs and confirm improvement.
- Commit a
.blackfire.yamlassertion so the regression cannot sneak back.
// Before: N+1 inside a resource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'author' => $this->post->user->name, // two lazy loads per iteration
];
}
// After: eager-load in the controller
$comments = Comment::with('post.user')->paginate(50);
Blackfire's comparison view will show metrics.sql.queries.count drop from 101 to 2 — a number you can screenshot and put in the PR description.
Key Takeaways
- Use Xdebug locally for full call graphs; use Blackfire in staging/CI for low-overhead continuous profiling.
- Sort Xdebug call graphs by self cost first, then drill into high-inclusive-cost callers.
- Blackfire's SQL query count assertion in CI is the single most effective N+1 regression guard.
- Always profile before and after a fix; perceived improvements without data are just opinions.
- Keep profiling artefacts out of production — gate Xdebug behind an environment check or a trigger header.