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::selectappearing hundreds of times → N+1Illuminate\Container\Container::resolvewith high self cost → over-resolved singletons or missingsingleton()bindingsIlluminate\Routing\Router::runRouteWithinStackwith 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.countassertion is the most reliable CI guard against N+1 regressions. - High
Container::resolveself cost usually means missingsingleton()bindings or service providers doing work inregister()that belongs inboot(). - 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.