Why Guessing Is Expensive
Most performance work in Laravel starts with a hunch: "It's probably the database" or "Maybe we need Redis here." Hunches are expensive. A profiler gives you a call graph with wall-clock time and memory allocations per function call. You stop optimising the wrong thing.
This article covers two tools with distinct roles:
- Xdebug — free, local, great for deep call-stack traces and code-coverage-adjacent profiling.
- Blackfire — commercial (free tier available), designed for continuous profiling, CI assertions, and production-safe sampling.
Xdebug: Local Call-Graph Profiling
Enable profiling output in php.ini (or a .ini drop-in for Sail/Herd):
[xdebug]
xdebug.mode = profile
xdebug.output_dir = /tmp/xdebug
xdebug.profiler_output_name = cachegrind.out.%p.%r
Trigger a single request profile without profiling every request:
xdebug.start_with_request = trigger
Then hit the route with the trigger query string:
curl "http://localhost/api/reports/monthly?XDEBUG_PROFILE=1"
Open the resulting cachegrind.out.* file in QCacheGrind (macOS: brew install qcachegrind). Sort by Self Cost to find functions that consume time independent of their callees — this is where actual work happens, not just delegation.
What to Look For
PDOStatement::executecalled hundreds of times → N+1 query.Illuminate\View\Compilers\BladeCompiler::compileStringin production → missing compiled view cache (php artisan view:cache).serialize/unserializein a hot path → over-eager session or cache serialisation.
Blackfire: Profiling With Intent
Blackfire's agent runs as a sidecar. Install the CLI and browser extension, then profile from the terminal:
blackfire curl http://localhost/api/reports/monthly
The output is a URL to an interactive call graph. More useful for CI is the assertion system.
Writing Performance Assertions
Create .blackfire.yaml at the project root:
tests:
"Monthly report endpoint is fast enough":
path: /api/reports/monthly
assertions:
- "main.wall_time < 300ms"
- "metrics.sql.queries.count < 10"
Run in CI:
blackfire validate --reference=1 --scenario=monthly_report
This turns performance into a first-class contract. A PR that introduces an N+1 fails the build before it ships.
Profiling Queued Jobs
Blackfire can profile CLI processes, which means you can profile a job in isolation:
blackfire run php artisan queue:work --once
Pair this with a seeded database and a known job payload so the profile is reproducible. Look for unexpected filesystem calls (file_get_contents on config files that should be cached) or repeated model hydration.
Practical Workflow
// Temporarily wrap a suspect code path with DB query logging
DB::enableQueryLog();
$report = app(MonthlyReportBuilder::class)->build($tenant);
dd(DB::getQueryLog()); // count, bindings, time per query
This is a quick sanity check before reaching for a profiler. If you see 80 queries for a report that should need 3, you already know where to look. The profiler then tells you which call site is responsible.
Takeaways
- Use Xdebug locally for deep call-graph exploration; sort by Self Cost, not Inclusive Cost.
- Use Blackfire for repeatable, assertion-driven profiling in CI and staging.
- Profile queued jobs the same way you profile HTTP requests — they have the same bottleneck patterns.
DB::enableQueryLog()is a fast first filter before opening a full profiler.- Never optimise without a baseline measurement; profilers give you before/after proof.
- Blade view compilation, config loading, and session serialisation are common non-obvious hotspots.