Why Guessing Is Expensive
Every senior engineer has shipped a "performance fix" that moved the wrong metric. Profiling tools exist precisely to replace intuition with evidence. Laravel's expressive API makes it easy to accidentally stack middleware, fire dozens of events, or trigger N+1 queries inside a resource collection — none of which dd(microtime()) will isolate cleanly.
This article covers two complementary tools: Xdebug for deep local traces and Blackfire for continuous, low-overhead profiling in staging and CI.
Xdebug: Cachegrind Traces for Local Deep Dives
Xdebug's profiler writes Cachegrind files you load in QCacheGrind or PhpStorm's built-in viewer. Enable it only on demand to avoid constant overhead:
; php.ini / xdebug.ini
xdebug.mode = profile
xdebug.start_with_request = trigger
xdebug.output_dir = /tmp/xdebug
xdebug.profiler_output_name = cachegrind.out.%p.%t
Trigger a single request with the XDEBUG_PROFILE cookie or query parameter:
curl -b 'XDEBUG_PROFILE=1' https://local.app/api/orders
Open the resulting file in QCacheGrind and sort by Self Cost — that column shows time spent inside a function excluding callees. A PDOStatement::execute call dominating the list is a clear N+1 signal. A json_encode spike inside a resource transform points at a serialisation problem.
Reading the Call Graph
The call graph view is more useful than the flat list for Laravel apps because the framework's call stack is deep. Look for:
- Wide fan-out nodes — a single controller method calling 30+ distinct functions often means over-eager eager loading or too many service resolutions.
- Recursive loops — Eloquent's
getRelationValueappearing hundreds of times is the classic lazy-load signature.
Blackfire: Continuous Profiling Without the Noise
Blackfire instruments PHP at the C extension level and samples at a rate that keeps overhead under 1 ms for most requests. Its killer feature is assertions — you write performance budgets in .blackfire.yaml and fail CI when they regress.
# .blackfire.yaml
tests:
"Order list endpoint stays fast":
path: /api/orders
assertions:
- "main.wall_time < 200ms"
- "metrics.sql.queries.count < 10"
Run a profile from the CLI against a staging environment:
blackfire curl https://staging.app/api/orders
The output URL opens an interactive call graph in the Blackfire UI. The Timeline tab is the most actionable view for Laravel: it shows Illuminate bootstrap, middleware stack, controller, and response serialisation as horizontal bands. A thick RouteServiceProvider band usually means too many route model bindings resolving eagerly.
Profiling Artisan Commands and Queue Jobs
Blackfire wraps CLI processes too, which is invaluable for queue workers:
blackfire run php artisan queue:work --once
This profiles a single job execution end-to-end. Combine it with a seeded job that exercises the hot path and you get reproducible traces you can compare across deploys.
Practical Workflow: From Trace to Fix
- Reproduce in isolation. Write a Pest test or a dedicated Artisan command that exercises only the slow path. Noise from other requests pollutes traces.
- Profile before touching code. Resist the urge to add
with()calls until the trace confirms the N+1. - Fix one thing at a time. Re-profile after each change. Two fixes applied together make it impossible to attribute the gain.
- Commit the Blackfire assertion. Lock in the budget so the next engineer can't accidentally regress it.
// Before: triggers N+1 inside OrderResource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'customer' => $this->order->customer->name, // lazy load
];
}
// After: eager load in the controller
$orders = Order::with('customer')->paginate();
The Blackfire timeline will show metrics.sql.queries.count drop from 51 to 2 — a concrete, shareable proof of the fix.
Takeaways
- Use Xdebug Cachegrind for deep local investigation; sort by Self Cost to find hot functions.
- Use Blackfire for staging and CI; its assertions turn performance budgets into failing tests.
- Profile queue jobs and Artisan commands, not just HTTP endpoints.
- Fix one bottleneck per profiling session and re-profile to confirm the gain.
- Commit
.blackfire.yamlassertions alongside the fix to prevent regression.