Blackfire & Xdebug Profiling in Laravel: Finding Real Bottlenecks
#laravel #performance #profiling #blackfire #xdebug

Blackfire & Xdebug Profiling in Laravel: Finding Real Bottlenecks

4 min read Mohamed Said Mohamed Said

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::execute appearing 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

  1. Reproduce the slow request in a local or staging environment.
  2. Xdebug profile to get the full call graph with exact timings.
  3. Fix the obvious wins: eager-load relations, add missing indexes, reduce serialisation payload.
  4. Blackfire profile before and after to compare call graphs and confirm improvement.
  5. Commit a .blackfire.yaml assertion 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I run Blackfire in production Laravel apps?
Blackfire's sampling overhead is low enough for staging, but most teams avoid it in production due to compliance concerns and the risk of exposing profiling endpoints. Use it in a production-mirrored staging environment instead, and enforce budgets via CI assertions.
Q02 How do I profile a queued Laravel job with Blackfire?
Wrap the job dispatch in a Blackfire CLI call: `blackfire run php artisan queue:work --once`. This profiles a single job execution and returns a call graph URL, making it easy to spot slow serialisation or database calls inside jobs.
Q03 Xdebug profiling makes my app too slow to use locally. What can I do?
Set `xdebug.start_with_request=trigger` so profiling only activates when you send the `X-Xdebug-Profile` header or use the browser extension. This keeps normal requests at full speed and only instruments the specific request you care about.

Continue reading

More Articles

View all