Debugging WordPress

Configure debug logging per environment, read fatal errors and recovery mode, and trace problems in hooks, AJAX and REST requests.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Read the debug log
  • Find the cause with Query Monitor
  • Debug hooks, AJAX and REST requests
  • Isolate a faulty plugin or theme safely

Before you start

WordPress hides most errors by default, which is right for visitors and wrong for developers. This lesson sets up debugging properly, then works through the places WordPress bugs hide: fatal errors, hooks that do not fire, AJAX calls that return 0 and REST requests that fail quietly.

The general PHP method (reading messages, var_dump, halving the search space, Xdebug) is covered in PHP for Beginners and in the PHP course's lesson on Xdebug. Everything here builds on it.

The debug constants

All of these go in wp-config.php, above the "stop editing" line.

ConstantEffect
WP_DEBUGMaster switch. Reports all PHP errors, notices and WordPress deprecation and "doing it wrong" notices.
WP_DEBUG_LOGtrue writes errors to wp-content/debug.log. A path string writes to that file instead.
WP_DEBUG_DISPLAYWhether errors are printed in the page. Defaults to true when WP_DEBUG is on.
SCRIPT_DEBUGLoads unminified core CSS and JavaScript.
SAVEQUERIESRecords every database query in $wpdb->queries.
WP_ENVIRONMENT_TYPElocal, development, staging or production. Read with wp_get_environment_type().

Local development

PHP
define( 'WP_ENVIRONMENT_TYPE', 'local' );
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', true );
define( 'SCRIPT_DEBUG', true );

Staging or production, while investigating

PHP
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', '/var/log/wordpress/example-debug.log' );
define( 'WP_DEBUG_DISPLAY', false );
@ini_set( 'display_errors', '0' );

The default wp-content/debug.log is inside the web root, so anyone who guesses the URL can read it, including file paths and sometimes data. On any server reachable from the internet, log to a path outside the web root, or block the file at the web server. Turn WP_DEBUG off again when you are done.

Writing to the log yourself

error_log() writes to the same log when WP_DEBUG_LOG is set. Wrap structured values with print_r() or wp_json_encode():

PHP
error_log( 'myplugin order payload: ' . wp_json_encode( $payload ) );

Follow the log in a terminal while you click around:

Shell
tail -f wp-content/debug.log

Remove temporary logging before committing. A helper that only logs outside production keeps accidental leftovers harmless:

PHP
function myplugin_debug( string $label, $value = null ): void {
    if ( 'production' === wp_get_environment_type() ) {
        return;
    }

    error_log( $label . ( null === $value ? '' : ': ' . print_r( $value, true ) ) );
}

Fatal errors and recovery mode

When a plugin or theme causes a fatal error, current versions of WordPress catch it and show "There has been a critical error on this website" instead of a blank page. The fatal error handler also:

  • Emails the site administrator (or the address in RECOVERY_MODE_EMAIL) with the error and a recovery mode link.
  • In recovery mode, pauses the faulty plugin or theme for that session only, so you can log in and deactivate or fix it.

The email and the log contain the useful part: the message, file and line.

Plain text
PHP Fatal error:  Uncaught TypeError: myplugin_format_price(): Argument #1 ($amount) must be of type float, string given, called in /var/www/example.com/wp-content/plugins/myplugin/includes/cart.php on line 88 and defined in /var/www/example.com/wp-content/plugins/myplugin/includes/format.php:12
Stack trace:
#0 /var/www/example.com/wp-content/plugins/myplugin/includes/cart.php(88): myplugin_format_price('19.90')
#1 /var/www/example.com/wp-includes/class-wp-hook.php(324): myplugin_render_cart('')
#2 /var/www/example.com/wp-includes/plugin.php(205): WP_Hook->apply_filters('', Array)

Read it in this order:

  1. The message: a string was passed where a float was required.
  2. The plugin directory in the path: plugins/myplugin, so you know whose code it is.
  3. The first frame in your code: cart.php line 88 passes the wrong type.
  4. Frames through class-wp-hook.php: the code ran as a hook callback. Frames in core are rarely the cause; they show how your code was called.

If you are developing and want PHP's raw output instead of the handler, define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true ); turns it off. Keep it on in production.

Notices that point at bugs

With WP_DEBUG on, WordPress reports two special kinds of notice:

  • Deprecated: you called a function or hook that is being phased out. The message names the replacement.
  • "Called incorrectly" (from _doing_it_wrong()): you used an API in the wrong way or at the wrong time, such as enqueueing scripts before wp_enqueue_scripts, or registering a REST route without a permission_callback.

Treat both as bugs. They usually explain why something "works on my machine" and fails elsewhere.

Query Monitor for debugging

Query Monitor, introduced in the performance lesson, is also the best general debugging panel. Beyond queries it shows:

  • PHP errors and notices for the request, with the component responsible.
  • The template file and template hierarchy used, and which conditionals (is_single(), is_archive() and so on) are true.
  • The main query's variables and the rewrite rule that matched, which explains most "wrong page" bugs.
  • Every hook fired, with the callbacks and priorities attached.
  • Enqueued scripts and styles, including missing dependencies.
  • REST and AJAX request details.

You can also send your own values to its Logs panel:

PHP
do_action( 'qm/debug', $settings );

If Query Monitor is not active, nothing happens, so the call is safe, though it still belongs in development only.

Debugging hooks

"My callback never runs" is one of the most common WordPress bugs. Work through the causes:

  1. Is the file loaded? Add a log line at the top of the file.
  2. Is the hook name right? Typos fail silently. Check the Code Reference.
  3. Has the hook already fired? Adding an init callback from inside a wp_loaded callback does nothing; init is over.
  4. Does it accept the right arguments? If your callback needs three parameters, add_action() must pass 3 as the fourth argument.
  5. Did something remove it or return early at a different priority?

Inspect the state from your code:

PHP
add_action( 'wp_loaded', 'myplugin_inspect_hooks' );

function myplugin_inspect_hooks(): void {
    error_log( 'init fired ' . did_action( 'init' ) . ' time(s)' );
    error_log( 'the_content callback at priority: ' . var_export( has_filter( 'the_content', 'myplugin_add_cta' ), true ) );
}

did_action() returns how many times an action has fired. has_filter() returns the priority a callback is attached at, or false. Inside a callback, current_filter() tells you which hook is running, useful when one function is attached to several.

Debugging AJAX requests

Admin AJAX requests run through wp-admin/admin-ajax.php. Open the browser's network panel, find the request and read its response. Common results:

ResponseUsual cause
0 with status 400No callback registered for that action: wrong action name, or only wp_ajax_ registered for a logged-out visitor (who needs wp_ajax_nopriv_).
-1 or status 403Nonce check failed in check_ajax_referer(): missing, expired or wrong action.
HTML or a notice before the JSONPHP output a warning, breaking the JSON. Check the debug log.
Empty 200The callback ran but did not call wp_send_json_success() or wp_die().

Because errors in AJAX handlers are invisible on the page, keep WP_DEBUG_DISPLAY off for this kind of work and read the log instead. Displayed notices corrupt the JSON response and create a second bug.

Debugging REST requests

REST errors come back as JSON with a code, a message and a status, which makes them easier to read:

  • rest_no_route (404): wrong namespace, route or HTTP method, or the route was registered outside rest_api_init.
  • rest_forbidden or a similar code (401/403): the permission_callback returned false. Logged-in requests from JavaScript also need the X-WP-Nonce header, or WordPress treats the request as logged out.
  • rest_invalid_param (400): an argument failed its validate_callback or schema. The response says which.

Test outside the browser with WP-CLI, which can run code as a chosen user. rest_do_request() dispatches a request internally, without HTTP:

Shell
wp eval 'print_r( rest_do_request( new WP_REST_Request( "GET", "/myplugin/v1/stats" ) )->get_data() );' --user=admin

WP-CLI as a debugging tool

Shell
# Run a line of PHP inside WordPress.
wp eval 'var_dump( get_option( "permalink_structure" ) );'

# Interactive PHP shell with WordPress loaded.
wp shell

# Load WordPress without any plugins or the theme.
wp option get siteurl --skip-plugins --skip-themes

# Deactivate every plugin when the admin is unreachable (on a backup or staging copy first).
wp plugin deactivate --all

# Show WP-CLI's own debug output.
wp plugin list --debug

Isolating the cause

When you do not know which component is responsible, bisect:

  1. Reproduce the problem on staging or locally, never by experimenting on production.
  2. Switch to a default theme. If the problem goes, it is in the theme.
  3. Deactivate half the plugins. If the problem goes, it is in that half. Repeat until one plugin remains.
  4. Reactivate everything and confirm the fault returns with just that plugin active.

The free Health Check & Troubleshooting plugin offers a troubleshooting mode that disables plugins and switches theme for your session only, which helps when a production-only bug cannot be reproduced elsewhere.

Summary

  • Turn on WP_DEBUG and WP_DEBUG_LOG locally; on servers, log outside the web root with display off, and switch it off afterwards.
  • Read fatal errors from the message, the plugin path and the first frame in your code; recovery mode lets you log in and pause the culprit.
  • Treat deprecated and "called incorrectly" notices as bugs.
  • Use Query Monitor for errors, templates, the main query and hooks, and did_action(), has_filter() and current_filter() to inspect hooks in code.
  • AJAX 0 means no handler, -1 means a failed nonce; REST errors name their cause in the code.
  • Use wp eval, wp shell and --skip-plugins to investigate, and bisect plugins on a copy of the site.

Further reading