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.
| Constant | Effect |
|---|---|
WP_DEBUG | Master switch. Reports all PHP errors, notices and WordPress deprecation and "doing it wrong" notices. |
WP_DEBUG_LOG | true writes errors to wp-content/debug.log. A path string writes to that file instead. |
WP_DEBUG_DISPLAY | Whether errors are printed in the page. Defaults to true when WP_DEBUG is on. |
SCRIPT_DEBUG | Loads unminified core CSS and JavaScript. |
SAVEQUERIES | Records every database query in $wpdb->queries. |
WP_ENVIRONMENT_TYPE | local, development, staging or production. Read with wp_get_environment_type(). |
Local development
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
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():
error_log( 'myplugin order payload: ' . wp_json_encode( $payload ) );
Follow the log in a terminal while you click around:
tail -f wp-content/debug.log
Remove temporary logging before committing. A helper that only logs outside production keeps accidental leftovers harmless:
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.
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:
- The message: a string was passed where a float was required.
- The plugin directory in the path:
plugins/myplugin, so you know whose code it is. - The first frame in your code:
cart.phpline 88 passes the wrong type. - 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 beforewp_enqueue_scripts, or registering a REST route without apermission_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:
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:
- Is the file loaded? Add a log line at the top of the file.
- Is the hook name right? Typos fail silently. Check the Code Reference.
- Has the hook already fired? Adding an
initcallback from inside awp_loadedcallback does nothing;initis over. - Does it accept the right arguments? If your callback needs three parameters,
add_action()must pass3as the fourth argument. - Did something remove it or return early at a different priority?
Inspect the state from your code:
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:
| Response | Usual cause |
|---|---|
0 with status 400 | No 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 403 | Nonce check failed in check_ajax_referer(): missing, expired or wrong action. |
| HTML or a notice before the JSON | PHP output a warning, breaking the JSON. Check the debug log. |
Empty 200 | The 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 outsiderest_api_init.rest_forbiddenor a similar code (401/403): thepermission_callbackreturned false. Logged-in requests from JavaScript also need theX-WP-Nonceheader, or WordPress treats the request as logged out.rest_invalid_param(400): an argument failed itsvalidate_callbackor 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:
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
# 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:
- Reproduce the problem on staging or locally, never by experimenting on production.
- Switch to a default theme. If the problem goes, it is in the theme.
- Deactivate half the plugins. If the problem goes, it is in that half. Repeat until one plugin remains.
- 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_DEBUGandWP_DEBUG_LOGlocally; 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()andcurrent_filter()to inspect hooks in code. - AJAX
0means no handler,-1means a failed nonce; REST errors name their cause in thecode. - Use
wp eval,wp shelland--skip-pluginsto investigate, and bisect plugins on a copy of the site.