Profiling Laravel with Blackfire and Xdebug: Finding Real Bottlenecks
#laravel #performance #profiling #blackfire #xdebug

Profiling Laravel with Blackfire and Xdebug: Finding Real Bottlenecks

4 min read Mohamed Said Mohamed Said

Stop Guessing, Start Measuring

Most Laravel performance work starts with a hunch: "It's probably an N+1" or "The cache must be cold." Hunches waste time. A profiler shows you the exact call stack, wall time, and memory allocation for every microsecond of a request. This article covers a practical workflow using both Xdebug (for local deep dives) and Blackfire (for CI and staging gates).


Xdebug: Callgrind Profiles for Local Investigation

Xdebug's profiler writes Callgrind-format files that tools like QCacheGrind or PhpStorm's built-in viewer can parse.

Enable profiling on demand

Avoid always-on profiling — it tanks throughput. Use the trigger approach:

; php.ini / xdebug.ini
xdebug.mode = profile
xdebug.start_with_request = trigger
xdebug.output_dir = /tmp/xdebug
xdebug.profiler_output_name = cachegrind.out.%R.%t

Then trigger a profile with a cookie or query string:

curl -b 'XDEBUG_PROFILE=1' https://local.app/api/reports/summary

Open the resulting file in QCacheGrind and sort by Self Cost. You're looking for functions that consume time themselves, not just because they call expensive children.

What to look for in Laravel

  • Illuminate\Database\Connection::select appearing hundreds of times → N+1
  • Illuminate\Container\Container::resolve with high self cost → over-resolved singletons or missing singleton() bindings
  • Illuminate\Routing\Router::runRouteWithinStack with deep middleware chains → middleware doing redundant work per request

Blackfire: Continuous Performance Assertions

Blackfire's agent instruments PHP at the C extension level with negligible overhead (~1–3 %), making it safe for staging and even canary production traffic.

Install and profile via CLI

blackfire run php artisan tinker --execute="app(App\Services\ReportBuilder::class)->build(1);"

Or profile an HTTP request:

blackfire curl https://staging.app/api/reports/summary

Blackfire's web UI shows a flame graph with exclusive time per node — identical concept to Xdebug's self cost, but interactive and shareable.

Writing performance tests (Blackfire Builds)

Blackfire Builds let you assert performance budgets in CI:

# .blackfire.yaml
tests:
  "Report summary endpoint":
    path: /api/reports/summary
    assertions:
      - "main.wall_time < 300ms"
      - "main.peak_memory < 20mb"
      - "metrics.sql.queries.count < 10"

The metrics.sql.queries.count assertion is the cleanest way to enforce N+1 prevention in a pipeline — no custom middleware, no test doubles.


A Practical Workflow

Step 1 — Reproduce with Xdebug locally

Get the Callgrind file, identify the top three self-cost offenders. Don't fix anything yet.

Step 2 — Confirm with Blackfire on staging

Blackfire's timeline view shows when in the request lifecycle each call happens. A slow boot() in a service provider shows up clearly here — it fires before your controller even runs.

Step 3 — Fix one thing at a time

Common fixes and how to verify them:

// Before: resolved fresh every call inside a loop
foreach ($ids as $id) {
    $result = app(TaxCalculator::class)->calculate($id);
}

// After: bind as singleton so the container returns the same instance
$this->app->singleton(TaxCalculator::class);

After each fix, re-run the Blackfire CLI comparison:

blackfire curl --reference 1 --samples 5 https://staging.app/api/reports/summary

Blackfire's comparison view highlights regressions in red and improvements in green — no mental arithmetic required.

Step 4 — Gate it in CI

Add the .blackfire.yaml assertions to your GitHub Actions or GitLab CI pipeline. A PR that introduces a new N+1 fails the build before it reaches production.


Takeaways

  • Use Xdebug trigger mode locally to avoid always-on overhead; sort by self cost, not inclusive cost.
  • Blackfire's metrics.sql.queries.count assertion is the most reliable CI guard against N+1 regressions.
  • High Container::resolve self cost usually means missing singleton() bindings or service providers doing work in register() that belongs in boot().
  • Profile before optimizing — the bottleneck is almost never where you expect it.
  • Blackfire comparisons between two runs give you objective proof that a fix worked, not just a feeling.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use Blackfire and Xdebug at the same time?
No. Both are PHP extensions that instrument the Zend engine, and they conflict when loaded simultaneously. Use Xdebug for local Callgrind profiles and Blackfire for staging/CI assertions — switch between them by toggling which extension is active in your php.ini or Docker image.
Q02 Does Blackfire work with Laravel Octane?
Yes, but with caveats. Because Octane keeps the application bootstrapped between requests, Blackfire profiles will not include the service provider boot phase after the first request. Profile the first cold request separately to capture bootstrap cost, then profile subsequent warm requests for steady-state performance.
Q03 What is the difference between inclusive and exclusive (self) time in a profiler?
Inclusive time is the total time spent in a function including all its callees. Exclusive (self) time is only the time spent in the function body itself. For finding real bottlenecks, sort by self time — a function with high inclusive time may simply be your main controller calling many fast helpers, while a function with high self time is genuinely doing expensive work.

Continue reading

More Articles

View all