How to Profile a Slow PHP Application (Stop Guessing, Start Measuring)

Learn how to profile slow PHP the right way: Xdebug, Blackfire, Telescope, and a measure-fix-remeasure method that finds real hotspots.

Jakub Owsianka
Jakub Owsianka
PHP Developer, Programming Enthusiast
10 min read
How to Profile a Slow PHP Application (Stop Guessing, Start Measuring)

The first time I was asked to profile slow PHP on a production checkout, I did what most people do: I opened the controller, read it top to bottom, and started "fixing" things that looked expensive. Two hours later the page was exactly as slow, and I'd introduced a bug. The problem wasn't the loop I rewrote. It was a single database query firing 400 times.

That day taught me the one rule that actually matters here: you cannot fix what you haven't measured. Reading code and guessing where the time goes is a coin flip, and your intuition is usually wrong. This guide walks through how to profile a slow PHP application properly, which tools to reach for, and how to read what they tell you.

Why guessing fails (and what "slow" usually means)

Developers love to blame the language. "PHP is slow." Sometimes a tight CPU-bound loop really is the culprit, but in my experience that's the exception. The vast majority of slow requests I've profiled were waiting on something, not computing something:

  • A database query with no index scanning a million rows
  • The N+1 query pattern, where one query becomes hundreds
  • An external HTTP call to a payment gateway or a third-party API
  • Cache misses forcing recomputation on every request

That distinction matters because it tells you which number to look at. Profilers report two very different things:

  • Wall time is the clock on the wall: total elapsed time from start to finish, including every millisecond spent waiting on the disk, the database, or a network socket.
  • CPU time is how long the processor was actually busy executing your code.

If a request takes 900ms of wall time but only 40ms of CPU time, no amount of clever PHP refactoring will save you. Your code spent 860ms waiting. Chasing CPU optimizations there is like tuning the engine when the car is stuck in traffic.

The method: measure, find the hotspot, fix, re-measure

Every effective profiling session I've run follows the same loop. It's boring and it works.

  1. Reproduce the slowness reliably. A request you can't trigger on demand is a request you can't profile. Pin down the exact URL, payload, and data conditions.
  2. Measure with a profiler to get a full picture of where time goes.
  3. Find the single biggest hotspot. Not the top five. The one line, query, or function eating the most inclusive time.
  4. Fix that one thing.
  5. Re-measure. Confirm the number moved. If it didn't, revert and go back to step 3.

The re-measure step is the one people skip, and it's the most important. I've "optimized" code that shaved zero milliseconds because the real cost was elsewhere. Only the profiler tells you the truth.

Quick manual timing for a first sniff

Before installing anything, you can get a rough sense of where the time goes with two built-in functions. This is crude, but it's often enough to point you at the right half of the request.

<?php

$start = microtime(true);

// the block you suspect is slow
$results = $repository->buildMonthlyReport($accountId);

$elapsed = microtime(true) - $start;

// %.4f gives you seconds with sub-millisecond resolution
printf("report build: %.4f s, peak memory: %.2f MB\n",
    $elapsed,
    memory_get_peak_usage(true) / 1024 / 1024
);

microtime(true) returns the current Unix timestamp as a float (in seconds), so subtracting two readings gives you the wall time of the block between them. memory_get_peak_usage(true) returns the peak memory the script has used, in bytes; the true argument reports the real memory allocated from the system rather than what's actively in use.

This won't tell you which function inside buildMonthlyReport() is slow. For that you need a real profiler. But wrapping a few suspect blocks like this is a two-minute sanity check that often confirms whether you're looking at I/O or computation.

Xdebug: the free, deep profiler

Xdebug ships a full function-level profiler. On PHP 8.3-era setups the config lives in your php.ini (or a dedicated xdebug.ini) and is controlled by the xdebug.mode setting.

; enable the profiler
xdebug.mode=profile

; where the profile files land
xdebug.output_dir=/tmp/xdebug

; only profile when a request explicitly asks for it
xdebug.start_with_request=trigger

A note on xdebug.mode: it's the single switch that controls what Xdebug does. Setting it to profile turns on the profiler. You can combine modes with commas (for example develop,debug), but leave profiling off in normal development because it's heavy and writes large files.

Setting xdebug.start_with_request=trigger means Xdebug only profiles requests that carry the XDEBUG_TRIGGER signal (a XDEBUG_TRIGGER GET/POST parameter, cookie, or environment variable). That keeps you from profiling every single request and drowning /tmp/xdebug in files.

Xdebug writes its output in the cachegrind format, one file per profiled request, named like cachegrind.out.<pid> by default. You don't read these by hand. Open them in a viewer:

  • KCachegrind on Linux
  • QCachegrind on macOS and Windows
  • Webgrind, a small PHP web app, if you'd rather stay in the browser

Inside the viewer you're looking for two columns: self cost (time spent in a function excluding its children) and inclusive cost (the function plus everything it calls). Sort by inclusive cost to find where whole subtrees of your call graph are burning time, then drill in until self cost points at the actual offender.

For CLI scripts, the same config applies; trigger it by setting the environment variable before running:

XDEBUG_TRIGGER=1 php artisan report:generate

The catch with Xdebug's profiler is overhead. It instruments every function call, so the absolute timings are inflated and skewed toward call-heavy code. It's excellent for finding the shape of the problem and terrible for measuring true production latency.

Blackfire, Tideways, and SPX for lower-overhead profiling

When you need numbers closer to reality, or you want to profile something resembling production, there are purpose-built tools:

  • Blackfire uses a probe plus an agent and produces call graphs with far less overhead than Xdebug. Its comparison view (before/after a change) is genuinely useful for that re-measure step. You can profile CLI commands with blackfire run php your-script.php.
  • Tideways leans toward continuous production monitoring, sampling real traffic so you see what's slow for actual users rather than on your laptop.
  • SPX (Simple Profiling eXtension) is a free PHP extension with a built-in web UI. It's lighter than Xdebug and pleasant for quick local investigations without setting up an external service.

I reach for Xdebug or SPX locally, and Blackfire or Tideways when I need trustworthy timings or a production view. Pick based on whether you're chasing the shape of the problem or the real-world cost.

Laravel-side tools: where the queries hide

If you're on Laravel, half your profiling can happen without touching a PHP extension, because the slowness is so often in the database layer.

  • Laravel Debugbar drops a toolbar onto every page showing the number of queries, their individual timings, and the total time spent in the database. When I open a page and see "412 queries, 780ms", I don't need a call graph to know what's wrong.
  • Laravel Telescope records queries, requests, jobs, and more into a dashboard. Its request timeline and slow-query view are perfect for spotting the N+1 pattern and queries that run without an index.

The N+1 problem is the single most common cause of slow Laravel pages I've encountered: you load 50 records, then lazily access a relationship inside a loop, and each iteration fires another query. Debugbar makes it obvious because you'll see the same query repeated with different IDs. There's a full write-up on fixing it in the Eloquent N+1 query problem, and once you've cut the query count, caching the ones that remain (see caching queries in Laravel) and adding the right indexes (database indexing explained) is usually where the rest of the time goes.

A step-by-step profiling session

Here's how I'd actually approach a page that "feels slow", start to finish.

  1. Reproduce it. Load the page in your browser with Debugbar enabled, or curl the endpoint. Note the wall time.
  2. Split I/O from CPU. Check the query count and total DB time in Debugbar. If the database time is most of the wall time, stop; your problem is in the query layer, and a CPU profiler won't help.
  3. If it's the database, copy the slowest query, run it with EXPLAIN, and look for full table scans. Kill N+1 patterns with eager loading and add indexes where the EXPLAIN tells you to.
  4. If it's genuinely CPU or logic, enable Xdebug's profiler for that one request using the trigger, then open the cachegrind file in QCachegrind.
  5. Sort by inclusive cost, find the top subtree, and drill down to the function with the highest self cost. That's your hotspot.
  6. Fix exactly that, nothing else.
  7. Re-measure the same way you measured. Compare the new wall time against the old one. If it dropped, commit. If it didn't move, revert and pick the next hotspot.

Notice how few of these steps involve editing code. Profiling is mostly investigation. The fix is often small once you actually know where to point it.

FAQ

What's the difference between profiling and debugging?

Debugging answers "why is this wrong?" Profiling answers "why is this slow?" A debugger steps through logic to find incorrect behavior; a profiler measures where time and memory go so you can find performance hotspots. Xdebug happens to do both, but through different modes (xdebug.mode=debug versus xdebug.mode=profile).

Can I profile PHP in production safely?

Xdebug's profiler is too heavy for general production use because it instruments every call and writes large files. Sampling-based tools like Tideways and Blackfire are built for it, sampling a fraction of traffic with low overhead. If you must use Xdebug on a live box, gate it behind the start_with_request=trigger setting so only requests you explicitly mark get profiled, and clean up the output directory afterward.

Why is my CPU time low but the request still slow?

Because the request is spending its time waiting, not computing. Low CPU time with high wall time is the classic signature of an I/O-bound request: a slow database query, an unindexed table scan, or a blocking call to an external API. Look at your query log and any outbound HTTP calls before touching PHP logic.

Do I need a profiler if I already have Laravel Debugbar?

For query-heavy problems, Debugbar alone often solves it, and that's most Laravel slowness. But Debugbar can't see inside your PHP functions. When the time is being spent in computation rather than queries, you'll need a real profiler like Xdebug, SPX, or Blackfire to see the call graph.

Conclusion

Profiling a slow PHP application comes down to refusing to guess. Reproduce the problem, measure it, and let the numbers point you at the one hotspot that matters. Check whether you're bound by CPU or by I/O first, because that single fact decides which tool you even open: Debugbar and EXPLAIN for the database, Xdebug or Blackfire for the code itself.

Then fix one thing and measure again. That loop feels slower than diving in and rewriting code, but it's the only approach that reliably makes the number go down. The 400-query checkout I mentioned at the start? Once I finally profiled it instead of reading it, the fix was a single with() call and one index. Eight hundred milliseconds gone in two lines, because I stopped guessing and started measuring.

Share:
Jakub Owsianka
Written by

PHP & Laravel developer with 10+ years of experience. I write about modern backend, architecture, DevOps and AI for developers.

More about the author
Follow