MaxtDesign

Debugging WordPress Plugins Like a Pro

Master WordPress plugin debugging — Query Monitor, Xdebug, structured logging, and the workflows that turn an 'intermittent issue' into a fixed bug.

11 min readdebugging,WordPress,Xdebug,Query Monitor,WP_DEBUG,developer tools
M
MaxtDesign
Engineering

Most WordPress plugin bugs aren't complex — they're a hook firing in the wrong order, a filter that didn't return, a database query that's slow under real load, an unexpected interaction between two plugins. The skill that separates effective plugin developers from frustrated ones isn't cleverness; it's a debugging toolkit and the workflow muscle to use it. This is the working set we use on client work — what each tool is good for and how they fit together.

WP_DEBUG and friends — the foundation

Three constants in wp-config.php change debugging posture meaningfully. Set them on local and staging; never on production:

/* Show PHP notices and warnings. */
define( 'WP_DEBUG', true );

/* Send debug output to wp-content/debug.log instead of the screen. */
define( 'WP_DEBUG_LOG', true );

/* Don't display errors on the page (production-safe even on local). */
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', 0 );

/* Use unminified core JS/CSS — easier stack traces. */
define( 'SCRIPT_DEBUG', true );

/* Save individual queries for Query Monitor. */
define( 'SAVEQUERIES', true );

The combination of WP_DEBUG_LOG with WP_DEBUG_DISPLAY = false is the right shape: errors captured to a file you can tail -f, but never rendered into the page (which would leak paths and cause mysterious blank-screen issues).

Then in your plugin code:

if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
    error_log( 'Booking handler called with: ' . wp_json_encode( $payload ) );
}

error_log() writes to the file WP_DEBUG_LOG points to. Tail it from a terminal while you reproduce the bug.

Query Monitor — the most useful plugin you'll install

Query Monitor is the canonical WordPress debugging tool. It adds a panel to the admin bar surfacing:

  • Every database query for the request, with timing
  • Every hook fired, in order, with their callbacks
  • Every HTTP request the page made (REST API, external)
  • Memory usage, peak memory, PHP version, WP version
  • Capability checks, transient operations, cron events
  • Component-level breakdowns (which plugin owned the slow query?)

Install it on every dev environment. Disable in production. When a client says "the site is slow", install Query Monitor on a copy of production and watch the request — 80% of the time the answer is visible in the first 30 seconds.

The panel that pays for itself: Hooks & Actions. Filter by hook name and you see every callback registered, in priority order. When "something is modifying the title and I don't know what," this answers it immediately.

Xdebug — when var_dump isn't enough

For non-trivial bugs, Xdebug is the step debugger. Set breakpoints, step through code, inspect variables in their actual runtime state. In 2026 the setup is genuinely easy:

  • Local by Flywheel ships Xdebug pre-configured — toggle it on per-site
  • DDEV / Lando have Xdebug built into their Docker images; ddev xdebug on enables it
  • VS Code with the PHP Debug extension is the pleasant frontend — you set breakpoints in the editor and it just works

The minimum useful Xdebug config in your local PHP:

zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=trigger
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.discover_client_host=0

With start_with_request=trigger, Xdebug only attaches when you trigger it (cookie, GET param, browser extension) — so casual browsing isn't slowed by the debugger. The browser extension "Xdebug Helper" sets the trigger cookie with one click.

Step debugging is a 10x productivity boost when learning a new codebase or chasing a bug that depends on runtime state you can't print. The investment to set it up is one afternoon; the payoff is years of saved time.

Structured logging

For plugins that ship to production, build a thin logging wrapper rather than scattering error_log()calls. The wrapper centralizes the "is this verbose enough to log?" decision and gives you uniform context (timestamp, plugin slug, level):

namespace Mxt\Bookings\Util;

final class Logger {

    public static function info( string $message, array $context = [] ): void {
        self::write( 'info', $message, $context );
    }

    public static function warn( string $message, array $context = [] ): void {
        self::write( 'warn', $message, $context );
    }

    public static function error( string $message, array $context = [] ): void {
        self::write( 'error', $message, $context );
    }

    private static function write( string $level, string $message, array $context ): void {
        if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) && $level === 'info' ) {
            return;  // Skip info in production
        }

        error_log( sprintf(
            '[mxt-bookings:%s] %s %s',
            $level,
            $message,
            $context ? wp_json_encode( $context ) : ''
        ) );
    }
}

On production, point WP_DEBUG_LOGto a path your host's log shipper (Datadog, Cloudwatch, etc.) is watching. You get structured events out of the plugin without coupling to a specific log backend.

The hook-ordering bug — debugging recipe

A common bug: "my filter doesn't apply." Recipe:

  1. Open Query Monitor → Hooks & Actions → search for the hook name. Confirm your callback is registered. If not, the registration code didn't run.
  2. Confirm the priority — if another callback at higher priority is overriding your output, raise yours.
  3. For filters: confirm your callback returns a value. The most common single bug.
  4. Add an error_log()at the top of your callback. If it fires, the issue is your logic. If it doesn't, the issue is that the hook isn't firing where you expect.
  5. Step through with Xdebug if the data is non-trivial.

For the broader hook-system mental model, see Understanding WordPress Hooks: Actions and Filters.

Database query debugging

Query Monitor surfaces every query, but for one-off investigation:

global $wpdb;

$wpdb->show_errors();          // Turn on error reporting
$wpdb->suppress_errors( false );

$result = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}mxt_bookings WHERE customer_email = %s",
    $email
) );

if ( $wpdb->last_error ) {
    error_log( '[mxt-bookings] DB error: ' . $wpdb->last_error );
    error_log( '[mxt-bookings] DB query: ' . $wpdb->last_query );
}

For slow queries, ask the MySQL slow query log directly. Most managed hosts expose this; on local you enable via my.cnf:

[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 0.5

Conflict isolation — the "deactivate everything" dance

Plugin conflicts: a bug only reproduces with a specific combination of plugins. Standard isolation:

  1. Switch to a default theme (Twenty Twenty-Five)
  2. Deactivate all plugins except yours
  3. If the bug goes away, reactivate plugins one at a time until it returns
  4. The plugin you just activated is the conflict source

For sites where this is impractical (live production with dependencies), use a staging copy. WP-CLI makes this faster:

wp plugin deactivate --all
wp theme activate twentytwentyfive
# reproduce, observe, then re-enable selectively
wp plugin activate the-suspect-plugin

The intermittent bug — capturing more state

"It works on my machine" usually means the bug depends on environmental state you don't have visibility into. Strategies:

  • Add structured logging at the entry and exit of your code paths, capturing input + output. Reproduce on production- similar conditions.
  • Use Sentry (or equivalent) for production error capture with full stack traces, request context, and user agent
  • For client-reported bugs: Loom recordings are gold. The client's screen + clicks + console errors gives you more than any bug report.
  • For mobile-only bugs: BrowserStack or local USB-tethered mobile devices with remote DevTools

Browser-side debugging

Plugin bugs often span PHP and JS. The browser DevTools have the right primitives:

  • Console — JS errors, warnings, your console.log output
  • Network tab— every request the page makes, with payload and timing. Filter by "admin-ajax" or "wp-json" to see your plugin's requests
  • Application tab — cookies, localStorage, sessionStorage. Useful when state-related bugs appear
  • Performance / Lighthouse — flame graphs of JS execution, LCP attribution, INP debugging

Add SCRIPT_DEBUG to your wp-config — WordPress will serve unminified JS so stack traces actually point at readable code.

The debugging mindset

A few principles that separate effective debugging from random thrashing:

  • Reproduce first.A bug you can't reproduce reliably is a bug you can't debug. Spend the time to find the minimal reproduction case before you start poking at code.
  • One change at a time.If you change three things and the bug goes away, you don't know which change fixed it.
  • Bisect. When the bug appeared between version A and version B, git bisect across that range. The commit that introduced the bug is informative even if you already know how to fix it.
  • Fix the root cause.If your fix is "wrap it in a try/catch and silently continue," you're burying the problem. Fix the cause; if you can't, log loudly so you have telemetry on how often the case happens.
  • Write the regression test.Once you've fixed it, add a test that would've caught it. Otherwise you'll fix it again in six months.

The WordPress debugging toolkit hasn't changed dramatically in the last few years — what's changed is how easily Xdebug now sets up, how good Query Monitor has gotten, and how cheaply structured logging can ship to a managed log destination. Most of the work is muscle memory: do the right setup once, then reach for the right tool when a bug surfaces. For the local dev environment that hosts all this, see Setting Up a Local WordPress Development Environment. For plugin scaffolds that ship logging and i18n right from the start, see WordPress Plugin Boilerplate Setup.

Need help putting this into practice?

MaxtDesign builds the AI-powered web stacks the articles describe — from agentic workflows to performance-first WordPress + WooCommerce. Talk to us about your project.