WordPress Debug Logging and Developer Tooling Workflow

A proper WordPress debugging workflow goes beyond enabling WP_DEBUG — it involves structured logging, query monitoring, tracking slow hooks, and making debug output accessible only to developers without exposing it to users. Getting this right speeds up plugin development and catches bugs before they reach production.

Problem: WordPress debug logging requires manually toggling WP_DEBUG and WP_DEBUG_LOG in wp-config.php — there is no standard developer tooling workflow for capturing, filtering, and rotating WordPress-specific debug output in a local environment.

Solution: Define WP_DEBUG, WP_DEBUG_LOG, and WP_DEBUG_DISPLAY based on an environment variable (getenv('WP_ENV')) so production never accidentally enables debug output. Use Query Monitor for browser-side debugging, tail -f wp-content/debug.log | grep ERROR for terminal monitoring, and configure log rotation with logrotate to prevent unbounded growth.

The examples below configure wp-config.php for safe debug logging, add a structured logging helper, integrate with Query Monitor, and show how to debug a slow hook or action using timing.

<?php
// wp-config.php — Development environment debug settings
// NEVER enable WP_DEBUG_DISPLAY on production — exposes internal paths and data

define( 'WP_DEBUG',         true );   // enable error reporting
define( 'WP_DEBUG_LOG',     true );   // write errors to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false );  // do NOT show errors in browser
define( 'SCRIPT_DEBUG',     true );   // load unminified .js/.css assets
define( 'SAVEQUERIES',      true );   // populate $wpdb->queries (Query Monitor uses this)

// Set custom log file path (optional — default is wp-content/debug.log)
// ini_set( 'error_log', '/var/log/wp-debug.log' );

// Suppress specific notices if a library generates noise (use sparingly)
// error_reporting( E_ALL & ~E_DEPRECATED & ~E_NOTICE );

Structured logging helper and hook timing:

<?php
// wp-content/mu-plugins/debug-helpers.php
// Only load in dev — check environment before loading
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) return;

/**
 * Structured debug logger — writes JSON lines to debug.log
 * Usage: myplugin_log( 'api_response', [ 'status' => 200, 'body' => $data ] );
 */
function myplugin_log( string $event, mixed $context = null, string $level = 'debug' ): void {
    $entry = [
        'time'    => date( 'Y-m-d H:i:s' ),
        'level'   => $level,
        'event'   => $event,
        'context' => $context,
        'request' => $_SERVER['REQUEST_URI'] ?? 'cli',
    ];
    error_log( wp_json_encode( $entry ) );
}

// Hook timing — find slow hooks in your plugin
function myplugin_time_hook( string $hook, callable $callback, int $priority = 10 ): void {
    add_action( $hook, function() use ( $hook, $callback ) {
        $start  = microtime( true );
        call_user_func_array( $callback, func_get_args() );
        $elapsed = round( ( microtime( true ) - $start ) * 1000, 2 );
        if ( $elapsed > 10 ) { // only log hooks taking > 10ms
            myplugin_log( 'slow_hook', [ 'hook' => $hook, 'ms' => $elapsed ], 'warning' );
        }
    }, $priority );
}

// Debug backtrace to find who called a function
function myplugin_trace( string $label = '' ): void {
    $trace = wp_debug_backtrace_summary( null, 2, false );
    myplugin_log( $label ?: 'trace', $trace );
}

// Dump $wpdb->queries after page load (requires SAVEQUERIES = true)
add_action( 'shutdown', function() {
    global $wpdb;
    if ( empty( $wpdb->queries ) ) return;

    $slow = array_filter( $wpdb->queries, fn( $q ) => $q[1] > 0.05 ); // > 50ms
    if ( $slow ) {
        myplugin_log( 'slow_queries', array_map(
            fn( $q ) => [ 'sql' => substr( $q[0], 0, 200 ), 'time_ms' => round( $q[1] * 1000, 2 ) ],
            $slow
        ), 'warning' );
    }
} );

<?php
// ── QUERY MONITOR INTEGRATION ──
// Query Monitor (free plugin) shows all queries, hooks, HTTP requests, and errors
// in the admin bar for logged-in developers.

// Log data to the QM Logs panel from your plugin code:
do_action( 'qm/debug',   'Message at debug level: {value}',   [ 'value' => $data ] );
do_action( 'qm/warning', 'Slow operation detected: {ms}ms',   [ 'ms'    => $elapsed ] );
do_action( 'qm/error',   'API call failed: {code}',           [ 'code'  => $status ] );

// Add a custom QM data collector (advanced — shows your plugin data in a QM panel)
add_filter( 'qm/collectors', function( array $collectors, QM_Plugin $qm ): array {
    require_once plugin_dir_path( __FILE__ ) . 'class-qm-collector-myplugin.php';
    $collectors['myplugin'] = new QM_Collector_MyPlugin();
    return $collectors;
}, 10, 2 );

// ── XDEBUG STEP DEBUGGING ──
// Install: pecl install xdebug
// /etc/php/8.3/fpm/conf.d/20-xdebug.ini:
// zend_extension=xdebug.so
// xdebug.mode=debug,develop
// xdebug.start_with_request=trigger   <- only activate when XDEBUG_SESSION cookie set
// xdebug.client_host=127.0.0.1
// xdebug.client_port=9003

// Use the VS Code PHP Debug extension or PhpStorm to connect.
// Set a breakpoint, open the site with ?XDEBUG_SESSION_START=PHPSTORM in the URL.

NOTE: Never commit wp-config.php with WP_DEBUG_LOG = true to a shared repo — the debug log can contain passwords, API keys, and user data in stack traces. Use environment-specific configs by including a wp-config-local.php file and adding it to .gitignore. The Query Monitor plugin is the single best investment for WordPress development productivity — it makes invisible problems visible without any code changes.

Leave Comment

Your email address will not be published. Required fields are marked *