Profile a Slow Laravel Endpoint: SPX, Blackfire and Reading a Flame Graph Properly

Laravel profiling done properly: rule out the database, capture an SPX flame graph, read it by self cost instead of width, then fail CI when it regresses.

Steven Richardson
Steven Richardson
· 15 min read

Four seconds. The team had already tried three fixes: a cache on the controller, an index on orders.created_at, and an ->with() on two relationships. The endpoint was still four seconds, and nobody could say where the time went.

That is the normal state of affairs, because Laravel profiling stops being taught at "here is how to install Xdebug". The install is the easy part. The part that matters — looking at a wall of orange rectangles and knowing which one to click — is the part every tutorial skips. This is the loop from a slow endpoint to a merged fix, using tools that cost nothing.

Every configuration directive below is verified against the current php-spx, Xdebug 3.4 and Blackfire documentation. The profile shapes are shapes, not measurements from your app — the numbers you should trust are the ones your own run produces.

Confirm the endpoint is slow with a repeatable measurement#

Before installing anything, get a number you can reproduce. "It feels slow" cannot be profiled, and a single browser reload includes DNS, TLS, asset loading and whatever your laptop was doing at the time. Hit the route ten times from the command line and look at the distribution, not the mean.

# Ten sequential requests, server-side time only
for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{time_starttransfer}\n" \
    -H "Accept: application/json" \
    -H "Authorization: Bearer $TOKEN" \
    https://staging.example.com/api/orders
done | sort -n

time_starttransfer is time to first byte, which is the closest curl gets to "how long did PHP take". If the spread is 3.9s–4.2s you have a deterministic problem and profiling will find it. If it is 0.2s–4.1s you have a conditional problem — a cold cache, a slow third party, one tenant with more data — and you need to profile the slow case specifically, not an average.

Knowing which endpoint to point this at is a different job from profiling, and it belongs to your APM. If you have not got that layer yet, Pulse, Nightwatch and OpenTelemetry each answer the "which route" question at different price points. Reach for a profiler only once the route is named.

Rule out the database before you install a profiler#

Most "slow PHP" is slow SQL, and you can prove it in thirty seconds without any extension at all. Register a listener that counts queries and sums their time, then compare that total against the request total.

// app/Http/Middleware/LogQueryCost.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class LogQueryCost
{
    public function handle(Request $request, Closure $next): Response
    {
        $count = 0;
        $queryMs = 0.0;

        DB::listen(function ($query) use (&$count, &$queryMs): void {
            $count++;
            $queryMs += $query->time; // milliseconds, as reported by the connection
        });

        $start = hrtime(true);
        $response = $next($request);
        $totalMs = (hrtime(true) - $start) / 1_000_000;

        Log::debug('query cost', [
            'route' => $request->path(),
            'queries' => $count,
            'query_ms' => round($queryMs, 1),
            'total_ms' => round($totalMs, 1),
            'php_ms' => round($totalMs - $queryMs, 1),
        ]);

        return $response;
    }
}

Read the result honestly. If it logs queries: 340, query_ms: 3100, total_ms: 3400, you have an N+1 and you are done — no profiler required. Fix the query count. If it logs queries: 6, query_ms: 40, total_ms: 3400, the time is genuinely inside PHP and the rest of this article applies.

This is the same question Telescope, Debugbar and Pulse answer with a nicer UI, and any of them will do. The middleware is here because it works in staging, in CI, and behind an API client that will never render a Debugbar.

Install SPX and capture your first profile#

SPX is a PHP extension that records every function call and ships its own browser UI — flame graph, timeline and flat profile — with no account and no data leaving your infrastructure. It supports PHP 5.4 through 8.5 on Linux, macOS and FreeBSD, on x86-64 or ARM64. Install it with PIE, or from source if you prefer.

# Via PIE (needs the PHP dev package and zlib headers)
pie install noisebynorthwest/php-spx

# Or from source
git clone https://github.com/NoiseByNorthwest/php-spx.git
cd php-spx && git checkout release/latest
phpize && ./configure && make && sudo make install

Then enable it. On a private development machine authentication is a formality, but SPX exposes a UI that can read your application's internals, so the key and the IP whitelist are both mandatory locks — never expose the UI on a public host.

; conf.d/spx.ini
extension=spx.so
spx.data_dir="/var/spx"
spx.http_enabled=1
spx.http_key="dev"                 ; openssl rand -hex 16 for anything shared
spx.http_ip_whitelist="127.0.0.1"
zlib.output_compression=0          ; or the UI renders as a blank page

Open http://your-app.test/?SPX_KEY=dev&SPX_UI_URI=/, switch profiling on, then hit the slow route. The report appears in the list underneath the control panel. For an API endpoint that a browser cannot authenticate against, drive it with cookies instead:

curl --cookie "SPX_ENABLED=1; SPX_KEY=dev" \
  -H "Authorization: Bearer $TOKEN" \
  https://staging.example.com/api/orders

If you are on macOS and would rather not compile anything, Herd ships a customised SPX build. Install it with the vendored script, and the dashboard appears on the /herd-profiler route of any site — see the Herd local development setup for how the PHP versions it manages fit together.

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/beyondcode/php-spx/HEAD/install.sh)"

One default will bite you here: SPX_BUILTINS is 0, so internal functions are not recorded. json_encode, preg_match and array_merge will be invisible, and their cost gets attributed to whichever userland function called them. Set SPX_BUILTINS=1 when you suspect a native function is the problem, and accept the extra noise.

Read the flame graph by self cost rather than width#

Open the flame graph and it will be dominated by one enormous frame at the bottom: Illuminate\Foundation\Http\Kernel::handle. It is 99% of the width, it is 99% of the width in every Laravel profile ever taken, and it is never the answer. That is the misreading that ends most profiling sessions.

A flame graph's x-axis is inclusive cost — this frame plus everything it called. The y-axis is stack depth, not time. Frames are not in chronological order; they are alphabetical within a level. So width tells you "the time passed through here", which for a framework entrypoint is a tautology.

What you want is self cost (SPX calls it Exc., for exclusive): time spent executing that function's own opcodes, excluding its children. Switch to the flat profile tab and sort by it. SPX already does this — its CLI flat profile sorts by exclusive cost by default, marking the sorted column with an asterisk:

Flat profile:

 Wall time           | ZE memory           |
 Inc.     | *Exc.    | Inc.     | Exc.     | Called   | Function
----------+----------+----------+----------+----------+----------
   2.41s  |   2.38s  |  184.2MB |  184.2MB |    4,812 | App\Models\Order::getTotalAttribute
   3.91s  |  310.4ms |  512.7MB |   18.3MB |        1 | App\Http\Controllers\OrderController::index
 ...

Read that top row the way it is meant to be read. OrderController::index has a huge inclusive cost and a small self cost — it is a supervisor, not a worker, and there is nothing in it to fix. getTotalAttribute has almost all of its inclusive cost as self cost and was called 4,812 times. That is your frame.

The technique, then, is: sort by self cost, take the top three functions, and only then find those frames in the flame graph so you can see who calls them. Reading the graph top-down is the mistake. You read the table first and use the graph to answer "why".

Three shapes account for most real Laravel profiles.

N+1 behind an accessor. A narrow tower repeated hundreds of times under a collection map or a Blade loop, with self cost concentrated in PDOStatement::execute or a getXAttribute method. The give-away is the call count, not the width of any single frame.

Unbounded hydration. A single wide frame with enormous self cost in Model::newFromBuilder, Model::setAttribute or the Arr:: helpers. Six queries, forty thousand models. The database was fine; turning rows into objects was not.

Response serialization. Self cost pooled in JsonResource::toArray and json_encode. This one hides unless you set SPX_BUILTINS=1, because json_encode is internal and its cost otherwise lands on the resource class that called it.

Trace the hot frame back to the line that causes it#

Click the hot frame in the flame graph and walk up the stack — towards the wider frames beneath it — until you hit a class in app/. That is the line you are going to change. Vendor frames are where the time is spent; application frames are where the decision that spent it was made.

For the accessor shape, the walk usually terminates somewhere like this:

// app/Models/Order.php — the hot frame
protected function total(): Attribute
{
    return Attribute::get(
        // One query per order, executed lazily inside a loop that
        // the controller never knew was a loop.
        fn (): int => $this->items()->sum('subtotal')
    );
}
// app/Http/Controllers/OrderController.php — the caller that causes it
return OrderResource::collection(
    Order::query()->latest()->paginate(50)
);

Nothing in the controller says "run 50 queries". The total accessor is invoked by OrderResource::toArray, once per record, and each invocation opens a fresh query. This is precisely the class of bug that Eloquent strict mode catches at development time by throwing on lazy loading — worth enabling before you need a profiler to find the next one.

The fix is withSum, so the aggregate is computed once by the database:

Order::query()
    ->withSum('items as total', 'subtotal') // one query, aggregated in SQL
    ->latest()
    ->paginate(50);

Capture a Cachegrind trace with Xdebug for the precise call counts#

SPX tells you a function was hot. Xdebug tells you it ran exactly 4,812 times and who called it every single time. Its profiler writes a Cachegrind file that KCachegrind, QCachegrind or webgrind will open, and its "All Callers" view is the most precise call-attribution tool available for PHP.

Use trigger mode, because the default for profile mode is start_with_request=yes — which profiles every request and fills the disk.

xdebug.mode=profile
xdebug.start_with_request=trigger   ; default for profile mode is "yes"
xdebug.output_dir=/tmp/xdebug
xdebug.trigger_value=StartProfileForMe
xdebug.use_compression=false        ; QCachegrind cannot read gzipped traces
curl -H "Authorization: Bearer $TOKEN" \
  "https://staging.example.com/api/orders?XDEBUG_TRIGGER=StartProfileForMe"
# Xdebug returns the filename in the X-Xdebug-Profile-Filename response header

Two things to be honest about. Xdebug's profiler roughly doubles wall time, so its absolute milliseconds are fiction and its ratios and call counts are gospel — never quote an Xdebug number as a response time. And use_compression defaults to true, which KCachegrind and PhpStorm handle and QCachegrind does not; if your trace will not open, that is why. If you already run Xdebug, note that PCOV is the faster choice for coverage and the two extensions should not be loaded together.

Profile with production-shaped data, not a seeded fixture#

A profile against 50 seeded orders is a profile of your seeder. The three shapes above are all volume-dependent: unbounded hydration is invisible at 50 rows and fatal at 40,000, and an N+1 that costs 8ms locally costs 3s against a database with real latency.

Shape matters as much as volume. A factory that gives every order exactly three items will never reproduce the tenant with 900 of them, and index selectivity that looks fine against uniformly random data collapses when 80% of rows belong to one customer.

// database/seeders/ProfilingSeeder.php — skew, not uniformity
$whales = Customer::factory()->count(5)->create();
$normal = Customer::factory()->count(2_000)->create();

Order::factory()
    ->count(40_000)
    ->recycle($normal)
    ->has(OrderItem::factory()->count(3))
    ->create();

Order::factory()
    ->count(10_000)
    ->recycle($whales)                        // 20% of orders, 0.25% of customers
    ->has(OrderItem::factory()->count(900))
    ->create();

An anonymised production restore beats any seeder if your legal position allows it. Where it does not, deliberately skewed factories get you most of the way, and they are the reason your staging profile and your local profile disagree.

Profile the queue workers and Artisan commands too#

The slowest code in most Laravel applications is not in a controller. It is in a nightly command or a queued job, where nobody is watching a spinner. SPX handles CLI natively — prepend an environment variable and the flat profile prints to STDERR when the script ends, including when you interrupt it with Ctrl-C.

# Flat profile straight to the terminal
SPX_ENABLED=1 php artisan reports:generate

# Or capture a full report for the web UI's flame graph
SPX_ENABLED=1 SPX_REPORT=full php artisan reports:generate

# Herd users
herd profile artisan reports:generate

A daemonised worker needs different handling, because profiling its entire lifespan tells you nothing about any individual job. Disable automatic start and bracket the work explicitly:

// A queue worker that profiles one job at a time
while ($job = $this->getNextJob()) {
    spx_profiler_start();

    try {
        $job->fire();
    } finally {
        $reportKey = spx_profiler_stop(); // returns the report key for the web UI
    }
}
SPX_ENABLED=1 SPX_REPORT=full SPX_AUTO_START=0 php artisan queue:work

That pairs naturally with how you already run workers under Supervisor in production — profile in a staging copy of the same process manager, not from a bare shell.

Match the profiled configuration to the one you actually run#

A profile is only comparable to production if the engine settings match, and two of them change the shape of the graph completely.

With OPcache off, compilation cost appears in every profile and swamps the frames you care about — you will "discover" that including Blade views is your bottleneck, which is true only on the machine where you turned OPcache off. Profile with the same OPcache and preloading configuration you run in production, and state which one that was when you share the result.

JIT does the same thing in reverse: it inlines and reorders enough that a JIT-enabled profile can be unrecognisable next to a JIT-disabled one. Pick one, note it, stay with it for the before and after.

Octane changes the picture more than either. Boot cost disappears after the first request, so the framework frames that dominate a traditional profile shrink to nothing and your application code is suddenly the whole graph. A memory leak shows up as a profile that gets worse across iterations — profile request 1 and request 500 and compare them, which is the single best leak detector available. If you run RoadRunner workers in production, profile against a warm worker or your numbers are meaningless.

Fix the bottleneck and re-profile to prove it#

Re-run the exact capture you started with — same route, same data, same OPcache settings — and put both flat profiles side by side. A fix without a re-profile is a guess with extra steps, and the second profile frequently shows that removing the first bottleneck merely promoted the second one.

Function Before (self) After (self) Calls before Calls after
Order::getTotalAttribute 2.38s 4,812 0
Model::newFromBuilder 310ms 295ms 40,050 50
JsonResource::toArray 88ms 91ms 50 50
Request total 3.91s 0.42s

Paste that table into the PR description. It survives review, it survives the next person wondering why the accessor looks strange, and it is the difference between "made it faster" and a change anyone can verify. If the fix was unbounded hydration rather than an N+1, the same discipline applies — and lazy collections keep the memory flat when the honest answer is that you cannot hydrate the set at all.

Set a performance budget that fails CI#

Nothing you fixed stays fixed. The accessor comes back in six weeks in a different resource class, and the only thing that prevents it is a build that goes red. This is the one Blackfire feature worth paying for: assertions in .blackfire.yaml that turn a performance budget into a failing test.

# .blackfire.yaml
tests:
    'Order listing stays cheap':
        path: '/api/orders'
        assertions:
            - 'metrics.sql.queries.count < 10'
            - 'main.peak_memory < 40mb'
        description: |
            The orders index regressed to 340 queries in 2026 because a
            model accessor aggregated per row. Keep the aggregate in SQL.

    'No page gets slower than its baseline':
        path: '/.*'
        assertions:
            - 'diff(metrics.sql.queries.count) < 2'
            - 'percent(main.wall_time) < 10%'

Assert on query counts and memory rather than wall time wherever you can — Blackfire's own guidance is that time is a symptom, and a CI runner's wall time is noisy enough to produce flaky builds. The diff() and percent() comparison assertions only evaluate during synthetic monitoring against a reference build, so use absolute budgets for pull-request checks and comparisons for scheduled runs.

# .github/workflows/perf.yml
- name: Install the Blackfire agent
  run: |
    curl -fsSL https://packages.blackfire.io/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/blackfire.gpg
    echo "deb [signed-by=/usr/share/keyrings/blackfire.gpg] http://packages.blackfire.io/debian any main" \
      | sudo tee /etc/apt/sources.list.d/blackfire.list
    sudo apt-get update && sudo apt-get install -y blackfire blackfire-php

- name: Assert the performance budget
  env:
    BLACKFIRE_CLIENT_ID: ${{ secrets.BLACKFIRE_CLIENT_ID }}
    BLACKFIRE_CLIENT_TOKEN: ${{ secrets.BLACKFIRE_CLIENT_TOKEN }}
  run: blackfire curl http://localhost:8000/api/orders   # non-zero exit on assertion failure

Blackfire is commercial and needs an agent on the machine, which is the honest cost. If that is out of reach, the cheap approximation is a Pest test that asserts a query count with DB::listen — less precise, free, and it still catches the accessor.

Wrapping Up#

Install SPX on staging behind a key, capture one profile of your worst endpoint, and sort the flat profile by exclusive cost. The top three rows are your afternoon. Everything else in this article is refinement on that one move.

Then close the loop properly: re-profile after the fix, paste both tables in the PR, and add an assertion so it cannot come back. If the profile pointed at the database rather than PHP, Eloquent strict mode stops the next N+1 before it ships, and Pulse or Nightwatch in production will tell you which endpoint to profile next.

FAQ#

How do I profile a slow Laravel endpoint?

Measure it repeatably first with ten curl requests, then rule out the database by logging query count and total query time for the request. If the time is genuinely in PHP, install the SPX extension, enable it behind a key, hit the route with SPX_ENABLED=1 set as a cookie, and open the report in SPX's web UI. Sort the flat profile by exclusive cost and start with the top three functions.

What is the difference between SPX, Xdebug and Blackfire?

SPX is a free tracing extension with a built-in browser UI, low enough overhead to leave enabled behind a key in staging, and it profiles CLI scripts as well as web requests. Xdebug's profiler is more precise about call counts but roughly doubles wall time and writes Cachegrind files you open in a separate tool, so it is a local microscope rather than a staging tool. Blackfire is commercial, runs an agent, and earns its licence through assertions that fail a CI build when a performance budget is breached.

How do I read a PHP flame graph?

The x-axis is inclusive cost and the y-axis is stack depth — frames are not in chronological order. The widest frame is always the framework entrypoint and is never the fix. Switch to the flat profile, sort by self or exclusive cost, take the top three functions, then find those frames in the graph and walk up the stack until you reach a class in your own app/ directory. That is the code you change.

Can I safely profile PHP in production?

SPX's own maintainers say it is not production ready, largely because its web UI would expose application internals to anyone who reached it, so treat staging as the ceiling. Xdebug's profiler is worse: it roughly doubles wall time and writes a large file per request, which will fill a production disk. Blackfire is the tool designed for production use, because it profiles a sampled subset of requests on demand rather than everything.

How do I find an N+1 query with a profiler instead of Telescope?

Look at the call count column, not the time column. An N+1 appears as a narrow tower repeated hundreds of times under a collection map or a Blade loop, with a getXAttribute method or PDOStatement::execute called once per record. Xdebug's Cachegrind output proves it exactly — its "All Callers" view shows the accessor was called 4,812 times and names every caller — where Telescope tells you the queries exist but not which PHP frame issued them.

How do I fail CI when an endpoint gets slower?

Write assertions in a .blackfire.yaml file, such as metrics.sql.queries.count < 10 or main.peak_memory < 40mb, and run blackfire curl against the endpoint in your pipeline — a failed assertion exits non-zero and fails the build. Prefer query-count and memory budgets over wall-time budgets, because CI runners are too noisy for reliable time assertions. Without a Blackfire licence, a Pest test that counts queries via DB::listen catches the same regression less precisely and for free.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.