Why "It Feels Slow" Is Not a Debugging Strategy
Every Laravel app eventually develops a slow endpoint. The instinct is to add a cache layer or throw an index at the database. Sometimes that works. More often, you've optimised the wrong thing entirely. Profiling tools give you evidence before you act.
This article focuses on two complementary tools: Xdebug for local call-graph analysis and Blackfire for timeline-based profiling in staging or production-like environments.
Xdebug: Call-Graph Profiling Locally
Xdebug's profiler writes a Cachegrind file you can open in KCachegrind (Linux) or QCachegrind (macOS/Windows). Enable it per-request so you don't drown in noise.
; php.ini or xdebug.ini
xdebug.mode=profile
xdebug.output_dir=/tmp/xdebug
xdebug.profiler_output_name=cachegrind.out.%p.%r
xdebug.start_with_request=trigger
Trigger a profile by appending ?XDEBUG_PROFILE=1 to any request, or set the cookie XDEBUG_PROFILE=1 for a session.
curl "http://localhost/api/reports?XDEBUG_PROFILE=1" \
-H "Authorization: Bearer $TOKEN"
Open the output file in QCachegrind and sort by Self Cost. You're looking for functions that consume time excluding their callees — those are the real culprits, not just the top of the call stack.
What to Look For
Illuminate\Database\Connection::selectcalled hundreds of times → N+1 queryIlluminate\Container\Container::resolvedominating → expensive service resolution on every requestjson_decode/serializein a hot loop → unnecessary (de)serialisation
Blackfire: Timeline Profiling for Realistic Environments
Blackfire instruments PHP at the C extension level with near-zero overhead, making it safe for staging. Its timeline view maps wall-clock time across layers: PHP, SQL, HTTP calls, and cache.
Install the agent and probe, then trigger a profile from the CLI:
blackfire curl https://staging.example.com/api/reports \
-H "Authorization: Bearer $TOKEN"
Or use the browser extension for authenticated sessions.
Reading the Call Graph
Blackfire's call graph shows inclusive time (a node + all descendants) and exclusive time (the node alone). Focus on nodes with high exclusive time and low call count first — they're doing expensive work in a single shot.
App\Http\Controllers\ReportController::index 42ms (excl: 0.3ms)
└─ App\Services\ReportBuilder::compile 41ms (excl: 38ms) ← HERE
└─ Illuminate\Support\Collection::map 3ms
In this example, ReportBuilder::compile is doing 38 ms of work itself. Drilling in reveals it's calling array_map over a 10 000-row result set that should have been aggregated in SQL.
Practical Workflow: Blackfire Assertions in CI
Blackfire supports .blackfire.yaml assertions so slow regressions fail the build:
# .blackfire.yaml
tests:
"Report endpoint stays fast":
path: "/api/reports"
assertions:
- "main.wall_time < 200ms"
- "metrics.sql.queries.count < 10"
Pair this with blackfire run php artisan test to profile your test suite's critical paths without a browser.
Combining Both Tools
Use Xdebug locally to explore unfamiliar code paths — the Cachegrind call graph is exhaustive. Switch to Blackfire when you need timeline context ("is this slow because of PHP or because of a slow query?") or when you want assertions in CI.
Neither tool replaces the other. Xdebug is a microscope; Blackfire is a dashboard.
Takeaways
- Enable Xdebug profiling per-request with
XDEBUG_PROFILEtriggers; never leave it on globally. - Sort Cachegrind output by Self Cost to find real hotspots, not just deep call stacks.
- Blackfire's timeline separates PHP time from I/O time — essential for diagnosing slow queries vs. slow code.
- Add
.blackfire.yamlassertions to CI to catch performance regressions before they reach production. - High exclusive time + low call count = expensive single operation; high call count + low exclusive time = N+1 pattern.