Debugging, Site Health and Troubleshooting

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

When a WordPress site breaks, the cause is almost always one of a short list: a plugin or theme that throws a fatal error, a database the site cannot reach, PHP running out of memory, a wrong URL or HTTPS setting, or two extensions that do not get along. WordPress has built-in tools for each: debugging constants in wp-config.php, a fatal error handler with a recovery mode for administrators, and the Site Health screen. This page explains how each tool works and then gives a runbook for each common failure, using the Lumen Press site and its lumen-catalog plugin as the example.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 8.4 (WordPress 7.1 requires PHP 7.4 at minimum and recommends 8.3+), MariaDB 11.8 and WP-CLI 2.12.0, and checked on 2026-10-28. Every output block below comes from the official wordpress:7.1.3-php8.4-apache and wordpress:cli-php8.4 images, with Mailpit receiving mail. WordPress 7.2 (in beta, due in December 2026) changes nothing described here so far. All constants mentioned here are documented in full on wp-config.php Reference; this page covers how they behave when something is wrong.

Where errors go

A PHP error in WordPress can end up in four places, and knowing which one is in use saves time:

  • PHP’s own error log (log_errors and error_log in php.ini; /dev/stderr in the official Docker image, so docker compose logs). Warnings and fatal errors land here even with WP_DEBUG off.

  • wp-content/debug.log or a custom file, with WP_DEBUG and WP_DEBUG_LOG. The default file is web-readable.

  • The page itself, with WP_DEBUG and WP_DEBUG_DISPLAY, or display_errors in php.ini. Every visitor sees it.

  • The admin mailbox, through the fatal error handler’s recovery mode (since 5.2).

On top of these, Site Health (Tools > Site Health) checks configuration, and plugins such as Query Monitor show errors, queries and hooks for the current request.

The debugging constants

The constants below are read in wp_initial_constants() and applied by wp_debug_mode(), both early in wp-settings.php. They must be defined in wp-config.php before the "stop editing" line; defining them in a plugin is too late.

WP_DEBUG

WP_DEBUG is the main switch. When it is true, wp_debug_mode() sets error_reporting( E_ALL ), so notices, deprecations and _doing_it_wrong() messages are reported. When it is false, WordPress lowers the reporting level to errors, warnings, parse errors and their user-level variants; notices and deprecations are not reported, but warnings still go to PHP’s error log. _deprecated_function() and _doing_it_wrong() only trigger their messages when WP_DEBUG is on.

If wp-config.php does not define WP_DEBUG, WordPress 7.1.3 defines it as true when WP_ENVIRONMENT_TYPE is development or when WP_DEVELOPMENT_MODE is not empty, and as false otherwise. local and staging do not turn it on. The official Docker image always writes define( 'WP_DEBUG', !!getenv_docker('WORDPRESS_DEBUG', '') );, so in containers the WORDPRESS_DEBUG environment variable decides, and the environment type has no effect on it.

WP_DEBUG_LOG and where debug.log lands

WP_DEBUG_LOG only works when WP_DEBUG is true. It accepts two kinds of value:

  • true (or '1', 'true'): PHP’s error_log is set to WP_CONTENT_DIR . '/debug.log', normally wp-content/debug.log.

  • A path string (since 5.1): errors go to that file. The directory must exist and be writable by the PHP user.

The lumen-noisy test plugin below produces one warning and one _doing_it_wrong() notice on every request. It is only for reproducing the behaviour on a local copy.

<?php
/**
 * Plugin Name: Lumen Noisy (test)
 */
add_action( 'wp_loaded', function () {
	$book = array();
	echo $book['lp_isbn']; // Warning: undefined array key.
	_doing_it_wrong( 'lumen_noisy', 'Called too early.', '7.1.0' );
} );

With the following settings and the plugin active, a request to the home page creates the log file. Requesting the file over HTTP then returns it to anyone:

wp config set WP_DEBUG true --raw
wp config set WP_DEBUG_LOG true --raw
wp config set WP_DEBUG_DISPLAY false --raw

docker compose exec wordpress ls -l wp-content/debug.log
docker compose exec wordpress curl -s -i -H 'Host: lumenpress.test' http://localhost/wp-content/debug.log
-rw-r--r-- 1 www-data www-data 1026 Oct 28 10:44 wp-content/debug.log
HTTP/1.1 200 OK

[28-Oct-2026 10:44:01 UTC] PHP Warning:  Undefined array key "lp_isbn" in /var/www/html/wp-content/plugins/lumen-noisy/lumen-noisy.php on line 7
[28-Oct-2026 10:44:01 UTC] PHP Notice:  Function lumen_noisy was called <strong>incorrectly</strong>. Called too early. Please see <a href="https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/">Debugging in WordPress</a> for more information. (This message was added in version 7.1.0.) in /var/www/html/wp-includes/functions.php on line 6260

The log can contain file paths, SQL, email addresses and anything else a plugin passes to error_log(). On any server reachable from the internet, either point WP_DEBUG_LOG at a path outside the web root or block the file at the web server. Site Health checks for this: with the default path its "Debugging enabled" test reports critical ("Your site is set to log errors to a potentially public file"), and with a path outside ABSPATH it reports recommended. Both results were reproduced on 7.1.3. Moving the log does not delete the old wp-content/debug.log; remove it by hand.

// wp-config.php on lumenpress.test (staging): log to a private directory.
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', '/var/log/wordpress/lumen-debug.log' ); // Directory owned by the PHP user, mode 750.
define( 'WP_DEBUG_DISPLAY', false );

If the file must stay in wp-content, deny it at the web server. The Apache rule below returned 403 for /wp-content/debug.log in the test container (Apache 2.4 syntax; Order/Deny lines from Apache 2.2 tutorials no longer apply). The nginx equivalent, which passed nginx -t on nginx 1.29, is location ~* /wp-content/debug\.log$ { deny all; } inside the server block.

# wp-content/.htaccess
<Files "debug.log">
    Require all denied
</Files>

WP_DEBUG_DISPLAY

WP_DEBUG_DISPLAY decides whether errors are printed into the page while WP_DEBUG is on. Its default is true, so define( 'WP_DEBUG', true ); alone prints the warning and notice shown above at the top of every page, before <!DOCTYPE html>, for every visitor. false forces display_errors off; null leaves display_errors as php.ini set it. WordPress always turns display off for REST, AJAX, XML-RPC and JSON requests, so an error there does not corrupt the response body, but it still appears in the log. WP-CLI prints PHP warnings on the terminal whatever the constant says. Site Health rates WP_DEBUG_DISPLAY with WP_DEBUG on as critical, or only recommended when wp_get_environment_type() is local or development.

SCRIPT_DEBUG

SCRIPT_DEBUG makes WordPress load the unminified versions of core JavaScript and CSS (.js instead of .min.js). It is useful when a stack trace in the browser console points into a core script, or when testing a patch to core scripts. It defaults to false, except in a wordpress-develop checkout whose version string contains -src. It does not change how plugin assets built with @wordpress/scripts are loaded; those use whatever file the plugin enqueues.

SAVEQUERIES

SAVEQUERIES makes wpdb store every query with its duration, the call stack and the start time in $wpdb->queries. It costs memory and time on every request, so enable it only while measuring. A quick check with wp eval:

wp config set SAVEQUERIES true --raw
wp eval 'global $wpdb;
new WP_Query( array( "post_type" => "post", "posts_per_page" => 5 ) );
$q = end( $wpdb->queries );
printf( "%d queries so far\nlast: %s\ntime: %.5f s\n", count( $wpdb->queries ), $q[0], $q[1] );'
wp config delete SAVEQUERIES
13 queries so far
last: SELECT t.*, tt.* FROM wp_terms AS t INNER JOIN wp_term_taxonomy AS tt ON t.term_id = tt.term_id WHERE t.term_id IN (1)
time: 0.00263 s

Each entry is an array: SQL, seconds, a comma-separated caller list, start time and custom data. Query Monitor (below) reads the same information and presents it per component, which is easier than reading the array.

Constant Local Staging Production Notes

WP_DEBUG

true

true

false

Turn it on briefly in production only with display off

WP_DEBUG_LOG

true

path outside web root

path outside web root, or false

Rotate the file; it grows without limit

WP_DEBUG_DISPLAY

true

false

false

Never true on a public site

SCRIPT_DEBUG

as needed

false

false

SAVEQUERIES

as needed

as needed

false

Measurable overhead

WP_ENVIRONMENT_TYPE

local

staging

production

See Local Development Environments

The fatal error handler and recovery mode

Before WordPress 5.2 a fatal PHP error in a plugin usually produced the "white screen of death": an empty page, often with HTTP 500, and no way into the dashboard. Since 5.2 WordPress registers a shutdown function, WP_Fatal_Error_Handler, that catches fatal errors (E_ERROR, E_PARSE, E_USER_ERROR, E_COMPILE_ERROR and E_RECOVERABLE_ERROR; uncaught exceptions and TypeError count as E_ERROR) and replaces the blank page with a short message and HTTP 500:

There has been a critical error on this website.
Learn more about troubleshooting WordPress.

The handler can be customised or switched off:

  • WP_DISABLE_FATAL_ERROR_HANDLER set to true (or the wp_fatal_error_handler_enabled filter returning false) turns it off. With display off, a fatal error then returns HTTP 500 with an empty body (0 bytes in the test), which is the old white screen.

  • A wp-content/fatal-error-handler.php drop-in can return an object that replaces the handler class.

  • A wp-content/php-error.php drop-in replaces the error page itself, for example with the site’s own branded template.

  • The wp_php_error_message and wp_php_error_args filters change the message and the wp_die() arguments.

When WordPress sends the recovery email

On top of the error page, WP_Recovery_Mode::handle_error() decides whether to email a recovery link. In the 7.1.3 source, confirmed by the reproduction below, three conditions must hold:

  1. The error’s file must be inside a plugin or theme directory. If the fatal is raised inside core (for example add_action( 'init', 'lp_catalog_register_meta' ) with a function that does not exist fails in wp-includes/class-wp-hook.php), WordPress cannot blame an extension, so no email and no pause happen. Network-activated plugins on Multisite are also excluded.

  2. The request must hit a protected endpoint: wp-login.php, a wp-admin screen (not AJAX), or one of a few AJAX actions used to fix errors, or anything allowed by the is_protected_endpoint filter. A fatal error on the front end alone never sends the email. The administrator only receives it after someone opens the login page or the dashboard.

  3. No recovery email must have been sent within the rate limit, one day by default (recovery_mode_email_rate_limit filter). The time of the last email is stored in the recovery_mode_email_last_sent option.

The email goes to the site’s admin email, or to RECOVERY_MODE_EMAIL if that constant holds a valid address (useful when the admin email is a shared mailbox nobody reads). The recovery_mode_email filter changes its content.

Reproducing recovery mode locally

The outputs below come from the stack described on Email Delivery: the official WordPress and MariaDB 11.8 images plus Mailpit, a local mail catcher, with a must-use plugin that sends wp_mail() to Mailpit through phpmailer_init. The differences were the default wp_ table prefix, WP_ENVIRONMENT_TYPE set to local, and no published port: requests were sent from inside the WordPress container with curl -H 'Host: lumenpress.test' http://localhost/.... The site must be installed with a host that contains a dot (wp core install --url=http://lumenpress.test ...), because wp_mail() builds the sender as wordpress@<host> and PHPMailer rejects wordpress@localhost with "Invalid address: (From)", so no recovery email would be sent.

A broken deploy of lumen-catalog is simulated by calling a function whose file was not shipped. The call is inside a closure in the plugin file, so the error is attributed to the plugin:

<?php
/**
 * Plugin Name:       Lumen Catalog
 * Version:           0.1.0
 * Requires at least: 6.9
 * Requires PHP:      7.4
 * Text Domain:       lumen-catalog
 */

defined( 'ABSPATH' ) || exit;

add_action(
	'init',
	static function () {
		// includes/meta.php, which defines this function, was missing from the deploy.
		lp_catalog_register_meta();
	}
);

After wp plugin activate lumen-catalog, the front end and the login page answer differently:

docker compose exec wordpress curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: lumenpress.test' http://localhost/
docker compose exec wordpress curl -s -H 'Host: lumenpress.test' http://localhost/wp-login.php
docker compose exec wordpress curl -s http://mailpit:8025/api/v1/message/latest

Source: Mailpit: API v1

500
There has been a critical error on this website. Please check your site admin email inbox for instructions.
If you continue to have problems, please try the support forums. Learn more about troubleshooting WordPress.

Subject: [Lumen Press] Your Site is Experiencing a Technical Issue
...
In this case, WordPress caught an error with one of your plugins, Lumen Catalog.
...
http://lumenpress.test/wp-login.php?action=enter_recovery_mode&rm_token=<token>&rm_key=<key>

To keep your site safe, this link will expire in 1 day. ...

Error Details
=============
An error of type E_ERROR was caused in line 16 of the file
/var/www/html/wp-content/plugins/lumen-catalog/lumen-catalog.php. Error message: Uncaught Error: Call to undefined
function lp_catalog_register_meta() in /var/www/html/wp-content/plugins/lumen-catalog/lumen-catalog.php:16

The front-end request produced only the short message and no email; the login page request produced the longer message and the email. Opening the link sets a wordpress_rec_* cookie and shows "Recovery Mode Initialized. Please log in to continue." After logging in, Plugins lists Lumen Catalog under Paused (1) with the error message, and the admin bar shows Exit Recovery Mode. The pause is stored in an option named <session id>_paused_extensions and applies only to the recovery session: in the same test, a visitor without the cookie still received HTTP 500 while the recovery session received 200. The fix is to deactivate, roll back or repair the plugin, then exit recovery mode.

Site Health

Tools > Site Health (wp-admin/site-health.php, since 5.2) has two tabs. Users need the view_site_health_checks capability, which WordPress grants to users who can install plugins (on Multisite, only to network administrators). A Site Health Status dashboard widget summarises the result, and a weekly cron event, wp_site_health_scheduled_check, re-runs the tests in the background.

Status

The Status tab runs direct tests in PHP when the page loads and async tests through the REST API (wp-site-health/v1/tests/...). Each test returns good, recommended or critical. In 7.1.3 the core tests are:

Kind Tests

Direct

WordPress version, plugin versions, theme versions, PHP version, PHP extensions, PHP default timezone, PHP sessions, database server version, secure communication (SSL support), scheduled events, HTTP requests (blocked hosts), REST API availability, debugging enabled, file uploads, plugin and theme auto-updates, temporary backup directory, disk space for updates, autoloaded options size, open registration with a privileged default role (7.0), search engine visibility (6.9), opcode cache (7.0)

Async

communication with WordPress.org, background updates, loopback request, HTTPS status, Authorization header (unless the site is behind basic auth)

Production only

page cache (async) and persistent object cache (direct); only when wp_get_environment_type() is production

The loopback test matters more than it looks: when the server cannot request its own URL, WP-Cron (unless a system cron runs it), scheduled posts and the error check of the theme and plugin file editors fail. WP-Cron and Scheduled Tasks covers the cron side.

Info

The Info tab lists the environment in collapsible sections: WordPress, directories and sizes, drop-ins, active and inactive themes, must-use and active plugins, media handling, server, database, WordPress constants and filesystem permissions. Copy site info to clipboard produces a plain-text report for support requests; fields marked private (for example database credentials) are left out of the copy.

Adding a test and an Info section

Plugins add tests with the site_status_tests filter and Info fields with the debug_information filter. The Lumen Catalog plugin checks that its distributor stock sync (the lp_catalog_sync_stock cron event, described on WP-Cron and Scheduled Tasks) is scheduled and not overdue, and adds a "Lumen Catalog" section to Info:

<?php
/**
 * Site Health integration of the Lumen Catalog plugin (includes/site-health.php).
 */

defined( 'ABSPATH' ) || exit;

add_filter(
	'site_status_tests',
	static function ( array $tests ): array {
		$tests['direct']['lp_catalog_stock_sync'] = array(
			'label' => __( 'Lumen Catalog stock synchronisation', 'lumen-catalog' ),
			'test'  => 'lp_catalog_site_health_stock_sync',
		);
		return $tests;
	}
);

/**
 * Checks that the stock sync cron event exists and is not long overdue.
 */
function lp_catalog_site_health_stock_sync(): array {
	$result = array(
		'label'       => __( 'Book stock is synchronised on schedule', 'lumen-catalog' ),
		'status'      => 'good',
		'badge'       => array(
			'label' => __( 'Lumen Catalog', 'lumen-catalog' ),
			'color' => 'blue',
		),
		'description' => '<p>' . esc_html__( 'The distributor stock sync is scheduled and ran recently.', 'lumen-catalog' ) . '</p>',
		'actions'     => '',
		'test'        => 'lp_catalog_stock_sync',
	);

	$next = wp_next_scheduled( 'lp_catalog_sync_stock' );

	if ( false === $next ) {
		$result['status']      = 'critical';
		$result['label']       = __( 'Book stock is not being synchronised', 'lumen-catalog' );
		$result['description'] = '<p>' . esc_html__( 'The lp_catalog_sync_stock event is not scheduled. Deactivate and reactivate Lumen Catalog to schedule it again.', 'lumen-catalog' ) . '</p>';
	} elseif ( $next < time() - HOUR_IN_SECONDS ) {
		$result['status']      = 'recommended';
		$result['label']       = __( 'The stock sync is overdue', 'lumen-catalog' );
		$result['description'] = '<p>' . esc_html__( 'The event is more than an hour late. WP-Cron may not be running: check DISABLE_WP_CRON and loopback requests.', 'lumen-catalog' ) . '</p>';
	}

	return $result;
}

add_filter(
	'debug_information',
	static function ( array $info ): array {
		$settings = get_option( 'lp_catalog_settings', array() );
		$next     = wp_next_scheduled( 'lp_catalog_sync_stock' );

		$info['lumen-catalog'] = array(
			'label'  => __( 'Lumen Catalog', 'lumen-catalog' ),
			'fields' => array(
				'books'           => array(
					'label' => __( 'Published books', 'lumen-catalog' ),
					'value' => (int) ( wp_count_posts( 'lp_book' )->publish ?? 0 ),
				),
				'distributor_url' => array(
					'label'   => __( 'Distributor API', 'lumen-catalog' ),
					'value'   => $settings['distributor_url'] ?? __( 'Not set', 'lumen-catalog' ),
					'private' => true, // Left out of "Copy site info to clipboard".
				),
				'next_sync'       => array(
					'label' => __( 'Next stock sync', 'lumen-catalog' ),
					'value' => $next ? gmdate( 'Y-m-d H:i:s', $next ) . ' UTC' : __( 'Not scheduled', 'lumen-catalog' ),
					'debug' => $next ? $next : 'not-scheduled',
				),
			),
		);
		return $info;
	}
);

A test result needs label, status, badge (label and a color such as blue, green, red, orange, purple or gray), description, actions and test. An async test is added under $tests['async'] with a REST URL in test and, optionally, an async_direct_test callable used when the result is computed without HTTP. The site_status_test_result filter can change any test’s result, including core ones. In an Info field, debug is the value used in the copied report instead of value, and 'private' => true removes the field from it.

Running tests from the command line

WP-CLI 2.12.0 has no bundled site-health command (wp help site-health reports "not a registered wp command"), but any test can be run with wp eval once the admin class is loaded. These runs exercised the three branches of the test above:

wp eval 'require_once ABSPATH . "wp-admin/includes/class-wp-site-health.php";
$t = WP_Site_Health::get_tests()["direct"]["lp_catalog_stock_sync"];
$r = call_user_func( $t["test"] );
printf( "%s: %s\n", $r["status"], $r["label"] );'

wp cron event schedule lp_catalog_sync_stock '-2 hours'    # an overdue single event
wp cron event delete lp_catalog_sync_stock
wp cron event schedule lp_catalog_sync_stock '+1 hour' hourly

Source: WP-CLI: wp eval

critical: Book stock is not being synchronised
recommended: The stock sync is overdue
good: Book stock is synchronised on schedule

WP_Debug_Data::debug_data() (in wp-admin/includes/class-wp-debug-data.php) returns the whole Info tab as an array keyed by section (wp-core, wp-server, wp-database, wp-constants and so on), which ended with the added lumen-catalog section in the test.

Query Monitor and other tools

Query Monitor (version 4.1.1, tested up to 7.1.3, at the time of checking) is the usual developer panel. It adds an admin-bar menu showing, for the current request, database queries grouped by plugin or theme with duplicates and slow queries highlighted, PHP errors with call stacks, _doing_it_wrong() and deprecation notices, the template and template parts used, blocks, hooks and their callbacks, enqueued scripts and styles, HTTP API calls, capability checks, rewrite rules and transients. It also reports on REST responses through headers for authenticated requests. Its output is only shown to users allowed to see it, but it should still be deactivated on production when not in use.

Other tools in the ecosystem:

  • Health Check & Troubleshooting (WordPress.org community plugin, version 1.7.1) was the origin of the core Site Health screen. Its remaining feature is Troubleshooting Mode: a session, only for the current user, in which every plugin is disabled and a default theme is active, while visitors still see the normal site. The plugin directory warns that it has not been tested with the last three major releases, so check it on staging before relying on it.

  • WP-CLI: wp --skip-plugins, wp --skip-themes, wp plugin deactivate, wp db check and wp eval work when the dashboard does not.

  • A PHP debugger such as Xdebug, described on PHP Debugging and Profiling.

Troubleshooting runbooks

The flowchart is the order in which the Lumen Press team narrows down a failure. Each box links to a runbook below.

flowchart TD A[Site broken] --> B{What does the browser show?} B -- Error establishing a database connection --> DB[Database runbook] B -- There has been a critical error --> F{Recovery email received?} F -- yes --> RM[Open the link, log in, fix the paused extension] F -- no --> LOG[Read debug.log or the PHP error log] B -- Blank page or HTTP 500 --> LOG B -- Too many redirects --> RD[Check home, siteurl and HTTPS behind the proxy] B -- Insecure content warnings --> MC[Replace http URLs with search-replace] B -- Cannot log in --> LK[Lockout runbook with WP-CLI] LOG --> M{Allowed memory size exhausted?} M -- yes --> MEM[Find the consumer, then raise WP_MEMORY_LIMIT] M -- no --> P{Error in a plugin or theme file?} P -- yes --> FIX[Deactivate it with wp --skip-plugins] P -- no or unclear --> BIS[Deactivate all plugins, switch to a default theme, reactivate one by one] BIS --> CORE{Still broken with everything off?} CORE -- yes --> HT[Check .htaccess or the server config, file permissions, core file integrity] CORE -- no --> FIX

First steps

  1. Find out what changed. Most failures follow an update, a deploy, a new plugin or a hosting change. Update history is in the plugin’s changelog and, for automatic updates, in the update emails.

  2. Back up before fixing. wp db export and a copy of wp-content, so a wrong fix can be undone (Backups and Disaster Recovery).

  3. Read the logs rather than guessing: debug.log, the PHP error log (docker compose logs wordpress, or the host’s log viewer) and the web server error log.

  4. Reproduce on staging whenever the production site is still up.

The critical error screen and the white screen

"There has been a critical error on this website" means the fatal error handler caught an error. An empty page with HTTP 500 means the handler is disabled, failed itself, or the error happened before WordPress loaded it (for example a parse error in wp-config.php).

  1. Check the admin mailbox for the recovery email. If nobody has opened the dashboard since the error, open /wp-login.php once to trigger it (front-end errors do not send it).

  2. Without email, read the last fatal error from the log: it names the file and line.

  3. Deactivate the extension named in the path. WP-CLI loads plugins too, so the broken plugin makes most commands fail with the same fatal error; skip loading it:

    wp plugin deactivate lumen-catalog --skip-plugins
    wp theme activate twentytwentyfive --skip-themes   # when the error is in the active theme
  4. Without shell access, rename the plugin’s folder over SFTP (lumen-catalog to lumen-catalog.off); WordPress deactivates a plugin whose file it cannot find. Renaming wp-content/plugins itself disables all plugins at once.

  5. If the error is in a must-use plugin or drop-in (wp-content/mu-plugins, object-cache.php, advanced-cache.php), move the file away; these cannot be deactivated from the dashboard.

HTTP 500 Internal Server Error

A 500 with the server’s own error page, not WordPress’s, usually comes from outside PHP code:

  • A broken .htaccess (a typo, or a directive whose module is not loaded). Rename it and save Settings > Permalinks to write a fresh one. Apache 2.2 directives such as Order deny,allow from older tutorials fail on servers without mod_access_compat; use Require.

  • PHP-FPM unavailable, a timeout at the proxy, or PHP crashing: check the web server error log.

  • Memory exhaustion or a fatal error with the handler disabled (see the runbooks around this one).

  • Wrong file ownership or permissions after a manual upload (File Permissions and Filesystem).

  • Damaged core files after a failed update: wp core verify-checksums lists modified or missing files, and wp core download --force --skip-content replaces them without touching wp-content.

"Error establishing a database connection"

WordPress shows this message, with HTTP 500, when wpdb cannot connect. Both causes below were reproduced, with the matching warnings from debug.log:

# Database container stopped (host does not resolve)
PHP Warning:  mysqli_real_connect(): (HY000/2002): php_network_getaddresses: getaddrinfo for db failed: Name or service not known in /var/www/html/wp-includes/class-wpdb.php on line 1990

# Wrong DB_PASSWORD in wp-config.php
PHP Warning:  mysqli_real_connect(): (HY000/1045): Access denied for user 'wordpress'@'172.22.0.4' (using password: YES) in /var/www/html/wp-includes/class-wpdb.php on line 1990

With WP_DEBUG and WP_DEBUG_DISPLAY on, the page adds the MySQL error and a checklist ("Are you sure you have the correct username and password?"); with display off, visitors see only the one-line message. A wp-content/db-error.php drop-in replaces this page, for example with a maintenance notice that sends 503 and Retry-After.

Work through the causes in this order:

  1. Credentials: compare DB_NAME, DB_USER, DB_PASSWORD and DB_HOST in wp-config.php with the database (wp config get DB_HOST). After a migration, DB_HOST is often still localhost while the new host needs a hostname, a port (db.internal:3307) or a socket (localhost:/run/mysqld/mysqld.sock).

  2. Server reachable: wp db check uses the same constants through the mariadb/mysql client and fails with the client’s own error, which is often clearer.

  3. Server running and not full: connection limits (Too many connections), a full disk or an exceeded hosting quota.

  4. Corrupted tables: a different message, "One or more database tables are unavailable", points to the repair page below.

  5. Compromise: a changed password in wp-config.php that nobody on the team made is a sign of intrusion; follow Security Hardening.

Repairing tables and WP_ALLOW_REPAIR

wp-admin/maint/repair.php runs CHECK TABLE and REPAIR TABLE (and optionally OPTIMIZE TABLE) on the core tables of the current site. It only works with this constant:

define( 'WP_ALLOW_REPAIR', true );

Two facts matter more than the feature itself, and both were checked on 7.1.3:

  • The page needs no login. It must work when the database is too damaged for authentication, so with the constant set, an anonymous request to /wp-admin/maint/repair.php?repair=1 ran the repair and listed "The wp_users table is okay." and so on for every table. Anyone can start repeated repairs or optimisations, which lock tables. Remove the constant as soon as the repair is done; the page itself asks for that.

  • It repairs tables and nothing else. It does not reset passwords, create users or fix data. One of the older books presents it as a way back in after a lost password; that is wrong. For lockouts, see the next runbook.

With shell access, wp db check, wp db repair and wp db optimize do the same work without exposing a page. InnoDB, the default engine, does not support REPAIR TABLE the way MyISAM did; real InnoDB corruption is handled with innodb_force_recovery and a restore from backup.

Memory exhaustion

The error names the limit in bytes and the allocation that failed. The lumen-memtest plugin below reproduces it on a local copy by allocating a string of the requested size:

<?php
/**
 * Plugin Name: Lumen Memory Test (local only)
 * Description: Reproduces memory exhaustion: request any page with ?lp_memtest=<MB>.
 */

defined( 'ABSPATH' ) || exit;

add_action(
	'template_redirect',
	static function () {
		if ( 'local' !== wp_get_environment_type() || ! isset( $_GET['lp_memtest'] ) ) {
			return;
		}
		$block = str_repeat( 'x', absint( $_GET['lp_memtest'] ) * MB_IN_BYTES );
		printf(
			"Allocated %d MB, memory_limit %s, peak %s\n",
			strlen( $block ) / MB_IN_BYTES,
			ini_get( 'memory_limit' ),
			size_format( memory_get_peak_usage( true ) )
		);
		exit;
	}
);

With the image’s memory_limit = 128M, ?lp_memtest=150 failed; after wp config set WP_MEMORY_LIMIT 256M it succeeded:

PHP Fatal error:  Allowed memory size of 134217728 bytes exhausted (tried to allocate 157290496 bytes) in /var/www/html/wp-content/plugins/lumen-memtest.php on line 15

Allocated 150 MB, memory_limit 256M, peak 204 MB

How the limits work in 7.1.3:

  • WP_MEMORY_LIMIT defaults to 40M (64M on Multisite) and applies to front-end requests; WP_MAX_MEMORY_LIMIT defaults to 256M and is applied by wp_raise_memory_limit() in the admin and for image processing.

  • WordPress only raises PHP’s memory_limit; with the image’s 128M, the default 40M changes nothing. Older books that describe the default as the effective limit, or give other defaults, are out of date.

  • If the host forbids changing memory_limit at runtime, both constants are ignored and WordPress uses the current value. Ask the host, or set it in php.ini or the PHP-FPM pool.

  • WP-CLI runs with memory_limit = -1 in the official image, so a command can succeed where a web request fails.

  • OPcache may keep serving the old wp-config.php for a moment: the official image sets opcache.revalidate_freq=2, and a request sent immediately after the change still failed with the 128M limit. Wait, or reload PHP-FPM.

Raising the limit is a workaround. The usual consumers are loops over all posts ('posts_per_page' => -1), large autoloaded options, image processing of huge uploads and runaway recursion in a filter; Query Monitor’s memory figure and memory_get_peak_usage() around suspect code find them.

Redirect loops

"Too many redirects" (ERR_TOO_MANY_REDIRECTS) has three usual causes:

  • HTTPS terminated at a proxy or load balancer. WordPress sees plain HTTP, FORCE_SSL_ADMIN or an https site URL makes it redirect to HTTPS, and the proxy forwards HTTP again. The signature is a redirect to the same URL with https:

    proxy did not send X-Forwarded-Proto:
    302 -> https://lumenpress.test/wp-login.php
    proxy sent X-Forwarded-Proto: https
    200 ->

    The fix is to make the proxy send X-Forwarded-Proto: https and to trust it in wp-config.php only when the proxy is the sole way in (the official Docker image’s wp-config.php already contains this):

    if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === $_SERVER['HTTP_X_FORWARDED_PROTO'] ) {
    	$_SERVER['HTTPS'] = 'on';
    }
  • Wrong home or siteurl. www against the bare domain, or http against https, combined with a server redirect in the opposite direction. Check with wp option get home and wp option get siteurl; when they are set by WP_HOME/WP_SITEURL in wp-config.php, the constants win.

  • A plugin or cache. Redirect, SSL and caching plugins, or a cached 301 in the browser or CDN. Test with curl -sIL --max-redirs 5 https://lumenpress.example/ rather than a browser, and purge caches after fixing.

Locked out of the dashboard

Every lockout has a WP-CLI answer that needs no email and no WP_ALLOW_REPAIR:

wp user list --role=administrator --fields=ID,user_login,user_email
wp user reset-password admin --skip-email --show-password   # prints a new password once
wp user session destroy admin --all                         # signs the user out everywhere
wp user create recovery recovery@lumenpress.example --role=administrator --prompt=user_pass
wp plugin deactivate <security-or-2fa-plugin> --skip-plugins

Typical situations: the password reset email never arrives (an email delivery problem, see Email Delivery); a security plugin has locked the IP after failed logins (deactivate it or clear its lockout table); a lost second factor (deactivate the two-factor plugin for the user); or the account lost its role (wp user set-role). Without a shell, the database route is a new bcrypt or phpass hash written through phpMyAdmin, which is error-prone; prefer the host’s WP-CLI or a support ticket. Delete any temporary administrator account afterwards.

Mixed content

After a switch to HTTPS, browsers block or flag images, scripts and styles still loaded over http://. They come from absolute URLs stored in post content, options and metadata. Check what would change, then run it for real:

wp search-replace 'http://lumenpress.test' 'https://lumenpress.test' --skip-columns=guid --dry-run --report-changed-only
Table	Column	Replacements	Type
wp_options	option_value	2	PHP
wp_posts	post_content	3	SQL
wp_users	user_url	1	SQL
Success: 6 replacements to be made.

wp search-replace unserializes PHP-serialized values (the PHP rows) so string lengths stay correct; a raw SQL REPLACE() would corrupt them. guid is skipped because feed readers use it as a permanent identifier. Hard-coded URLs in theme files, CSS and third-party embeds need fixing in code; the browser console names each offending URL. A Content-Security-Policy: upgrade-insecure-requests header is a stopgap, not a fix. Older tutorials suggest SQL UPDATE statements with REPLACE() for this; that breaks serialized options.

Plugin and theme conflicts: bisection

When no log line points to a culprit, for example a broken editor, a missing menu item or a slow page, turn extensions off and on until the problem appears. The safe sequence keeps a list of what was active:

wp plugin list --status=active --field=name --skip-plugins > active-plugins.txt
wp plugin deactivate --all --skip-plugins
wp theme activate twentytwentyfive --skip-themes   # only if the problem may be the theme

--skip-plugins is not optional when one plugin throws a fatal error: without it, wp plugin deactivate --all itself loaded the broken plugin and died with the same error. wp plugin deactivate --all does not remember the previous state, hence the file. The script below reactivates the plugins in order and stops at the first one after which the home page stops answering 200. Run it from the WordPress root:

#!/bin/sh
# Usage: find-culprit.sh <file-with-plugin-slugs> <url-to-check>
# Run from the WordPress root after `wp plugin deactivate --all --skip-plugins`.
# Reactivates the plugins one by one and stops at the first one that breaks the URL.
set -eu
list="$1"
url="$2"

while read -r plugin; do
  wp plugin activate "$plugin" --skip-plugins --quiet
  status=$(curl -s -o /dev/null -w '%{http_code}' "$url")
  echo "$plugin -> HTTP $status"
  if [ "$status" != "200" ]; then
    wp plugin deactivate "$plugin" --skip-plugins --quiet
    echo "Culprit: $plugin (deactivated again)"
    exit 1
  fi
done < "$list"
echo "No single plugin breaks $url: test the theme, then pairs of plugins."
akismet -> HTTP 200
hello -> HTTP 200
lumen-catalog -> HTTP 500
Culprit: lumen-catalog (deactivated again)

For problems an HTTP status cannot detect, replace the curl check with a manual test after each step. With many plugins, halve the list instead: activate the first half, test, and keep the half that shows the problem; 32 plugins need at most five rounds. A problem that only appears with two specific plugins active together shows up as "No single plugin breaks" and needs the halving approach with both halves combined. On production, Troubleshooting Mode of the Health Check plugin does the same for one user without affecting visitors.

Where older books are outdated

Older advice Current behaviour (7.1)

Define WP_ALLOW_REPAIR to reset a lost administrator password

It only checks, repairs and optimises tables, and exposes /wp-admin/maint/repair.php without login. Reset passwords with wp user reset-password or the "Lost your password?" link.

A fatal error always gives a white screen; the only fix is FTP

Since 5.2 the fatal error handler shows "There has been a critical error" and emails a recovery link, provided the error is in a plugin or theme file and someone opens the dashboard or login page.

Turn on WP_DEBUG to see errors

On its own it also prints errors to every visitor (WP_DEBUG_DISPLAY defaults to true). Pair it with WP_DEBUG_LOG and WP_DEBUG_DISPLAY = false, and keep the log out of the web root.

Memory limit defaults and "set WP_MEMORY_LIMIT to fix memory errors"

Defaults are 40M (64M Multisite) and 256M for WP_MAX_MEMORY_LIMIT; WordPress only raises PHP’s limit, and only where the host allows it. Find the consumer first.

Fix mixed content with SQL REPLACE() over the database

Breaks serialized data; use wp search-replace, which handles it.

Apache 2.2 Order/Deny/Allow rules to protect files

Use Apache 2.4 Require all denied; see Web Servers: Apache and nginx.