Developer Tools

A Real Xdebug Session: Finding an Off-By-One Bug Step by Step

A genuine Xdebug debugging session, breakpoint by breakpoint, tracing a pagination bug that only showed up on the last page of results — with the exact watch expressions that found it.

By Aissam Ait Ahmed Developer Tools 0 comments

A support ticket came in: "the last page of search results is missing an item." Reading the pagination code top to bottom didn't turn up anything obviously wrong — the logic looked correct on a plain read. This is the actual Xdebug session that found the bug, breakpoint by breakpoint, because reading code and stepping through its real execution with real data are genuinely different debugging tools, and this bug only showed up in the second one.

The setup: reproducing it locally first

Before opening a debugger at all, the bug needed to reproduce locally with a known, small dataset — debugging against production data you can't fully see the shape of wastes far more time than setting up a controlled case. Seeding exactly 23 test records with a page size of 10 reproduces the "last page missing an item" symptom reliably: page 3 should show 3 items and instead shows 2.

class SearchController
{
    public function results(Request $request)
    {
        $page = (int) $request->get('page', 1);
        $perPage = 10;
        $offset = ($page - 1) * $perPage;

        $total = Product::search($request->get('q'))->count();
        $items = Product::search($request->get('q'))
            ->skip($offset)
            ->take($perPage)
            ->get();

        $lastPage = ceil($total / $perPage);

        return view('results', compact('items', 'total', 'lastPage', 'page'));
    }
}

Setting the first breakpoint

With Xdebug configured and listening (in VS Code, clicking the line gutter; in PhpStorm, the same click-to-set gesture), the first breakpoint went on the line computing $offset, since that's the value most likely to be wrong in an off-by-one bug. Hitting /search?q=widget&page=3 with the debugger listening paused execution right there, with the Variables panel showing the actual live values instead of what the code merely implies they should be.

$page = (int) $request->get('page', 1);  // paused here, $page = 3
$perPage = 10;
$offset = ($page - 1) * $perPage;          // about to execute

Stepping over that line (F10 in most debuggers) and checking the Variables panel: $offset = 20. That's correct — page 3 with a page size of 10 should skip the first 20 items and show items 21, 22, 23. Nothing wrong yet. This is the value of stepping through with real state rather than just reading the formula: confirming each intermediate value is actually correct, one at a time, rather than assuming the whole expression is fine because it looks right.

Adding a watch expression for the part that mattered

Rather than keep stepping line by line through the search call itself (which involves library internals not worth stepping into), a watch expression on $items->count() lets the debugger surface that specific value at every future breakpoint without manually inspecting it each time.

// Watch expression added in the debugger panel:
$items->count()

Stepping past the $items = Product::search(...)->get() line, the watch showed 2 — but with $offset = 20 and 23 total records, items 21, 22, and 23 should all come back, which is 3 items, not 2. The bug was now isolated to somewhere inside that query, not in the offset math, which is exactly the kind of narrowing a debugger does well and reading code from the top rarely does as reliably.

Stepping into the search call to find the actual cause

Stepping into (F11) the skip()/take() chain rather than over it led to the underlying search library's query builder, and a second watch expression on the constructed query's raw parameters revealed the actual issue: the search library's skip() was being applied, but a separate, unrelated default limit configured earlier in the request pipeline — a global search result cap of 22, set as a safety limit for a completely different feature — was silently truncating results before pagination ever got a chance to run correctly.

// Found several stack frames deep, in a shared search service provider:
$searchQuery->take(min($requestedLimit, config('search.max_results'))); // max_results = 22

Page 3 requesting items 21–30 was being silently capped to a maximum of 22 total results across the whole result set, which is why item 23 never made it back — not a pagination math bug at all, but an unrelated global limit interacting badly with pagination on any dataset with more than 22 results. This is exactly the kind of bug that reading the pagination code in isolation, without stepping through the actual live call chain, would never surface — the bug wasn't in the file anyone was looking at.

Conditional breakpoints: skipping the noise on a larger reproduction

Once the fix was in place, verifying it against a larger 200-record dataset meant the debugger would otherwise pause on every single page load during manual testing — tedious when you only care about one specific page. A conditional breakpoint restricts pausing to exactly the case worth inspecting:

// Conditional breakpoint expression, set in the debugger's breakpoint properties:
$page == 20

This is one of the more underused Xdebug features — most people set a plain breakpoint and manually click "continue" repeatedly until the interesting case comes up, when a condition would just skip straight there. On the 200-record set with a page size of 10, that's the difference between clicking continue nineteen times and clicking it zero times.

Getting Xdebug listening in the first place

None of the above works without Xdebug actually installed and configured to talk to the editor, which is worth walking through explicitly since it's the step people give up on before ever setting a breakpoint. The php.ini side is a handful of lines:

zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=127.0.0.1
xdebug.client_port=9003

xdebug.mode=debug is the setting most people miss — older Xdebug versions were debug-only by default, but Xdebug 3 requires explicitly enabling debug mode alongside its other modes (coverage, profiling), and leaving it unset is the single most common reason "Xdebug is installed but nothing happens when I set a breakpoint" for anyone upgrading from an older tutorial. xdebug.start_with_request=yes means Xdebug attempts to connect on every request rather than only when a special cookie or query parameter is present, which is the simplest setup for local development even though it adds a small amount of overhead best avoided in production.

On the editor side, VS Code needs a launch.json listening on the matching port (covered with a working example in VS Code vs JetBrains), while PhpStorm's "Start Listening for PHP Debug Connections" button handles the equivalent step with no config file at all — genuinely one of the places PhpStorm's batteries-included approach saves real setup time over VS Code's more manual path.

A second bug this same session caught

With the debugger already attached and the watch expression still active, a second, smaller issue surfaced almost by accident: stepping through the same search call chain a second time to double-check the fix, the stack trace panel showed the search query executing twice per request — once for the actual results, and a second, identical, seemingly redundant call for a "did you mean" spell-check suggestion feature that fired unconditionally, even when the search term had zero typos and no suggestion would ever be shown. That's not a correctness bug, but it's a real, avoidable performance cost stacked on top of the pagination fix, and it was only visible because the debugger's call stack was already open and being read carefully rather than glanced at once and dismissed.

This is a genuinely common pattern with step debugging: the bug you went looking for is often not the only thing wrong in the code path you end up examining closely, and a debugging session is a reasonable moment to note a second issue for a follow-up fix, even if chasing it immediately would be scope creep on the original ticket.

Why reading the code first didn't find this

  • The bug lived in a different file (a shared search service provider) than the one being read (the pagination controller), so a top-to-bottom code review of the obviously-relevant file was never going to surface it.
  • The interacting limit was set for an unrelated feature months earlier, so nothing about the pagination code's own logic was actually wrong — the bug was an interaction, not a local mistake.
  • Watch expressions on intermediate values ($items->count()) narrowed the search space to "somewhere in the query building" long before stepping into any specific line, which is a genuinely different search strategy than reading code start to finish hoping something looks wrong.

What this debugging session cost versus what it saved

Setting up the local reproduction, configuring the first breakpoint, and stepping through to the actual cause took under twenty minutes once Xdebug was already configured (which itself is closer to zero-config in PhpStorm and needs a one-time launch.json setup in VS Code, a trade-off covered in more depth in VS Code vs JetBrains). Compare that to how long a code-reading-only investigation had already run before the debugger came out: the original engineer had spent close to an hour reading the pagination logic repeatedly, convinced the bug had to be there, before escalating. The debugger didn't require being smarter about the bug — it required being willing to stop guessing from reading code and start watching the actual execution instead.

If the URL query parameters involved in a bug report look suspicious — unexpected encoding, a stray character — running the raw query string through a proper URL encoder/decoder before assuming it's a code bug is a fast way to rule out "the input itself was malformed" before reaching for a debugger at all, since that's a different category of bug with a much faster fix once identified. Ruling out the cheap explanations first is what makes reaching for a full step-debugging session, when it's genuinely warranted, feel like a deliberate escalation rather than a first reflex for every reported bug regardless of how likely a debugger actually is to be the right tool for it.

Comments

Join the conversation on this article.

Comments are rendered server-side so the discussion stays visible to readers without relying on a separate widget or client-side app.

No comments yet.

Be the first visitor to add a thoughtful comment on this article.

Leave a comment

Share a useful thought, question, or response.

Be constructive, stay on topic, and avoid posting personal or sensitive information.

Back to Blog More in Developer Tools Free Resources Explore Tools