Guide

WordPress 500 internal server error — how to find the cause in logs

You open your WordPress site and the browser shows nothing but a sterile message: > 500 Internal Server Error No stack trace. No plugin name. No clue. The admin dashboard is gone too — /wp-admin returns the same blank 500.

1. Problem

The browser shows 500 Internal Server Error, or worse, nothing at all: a blank tab, a generic host error page, or a connection that just hangs. There is no stack trace, nothing in the response body you can act on. Whatever failed, failed on the server, and the server is not telling you why.

The advice you find first is generic: increase memory_limit, restore your .htaccess, deactivate all plugins. Any might be right, and trying them blind wastes an hour finding out which. A 500 is not one failure mode, it is a symptom several unrelated failures produce identically. The useful question is narrower: which log has the entry, and that depends on how much of the site the 500 covers.

That scope, every page versus one route versus only /wp-json versus only wp-admin, is the fastest way to narrow a 500 to a specific log and fix. It also tells you, honestly, when the cause is not PHP at all.

Most 500s that originate inside WordPress surface as the wp_php_fatals_total signal. This guide works through scope-first triage: where to look for each pattern, and why some 500s legitimately have no PHP-side record at all.

2. Impact

A 500 with no visible message does not just cost the request that failed. It costs the time spent guessing at the wrong layer, and it hides scoped failures behind a site that looks fine everywhere you happen to check.

  • Scoped 500s pass a homepage check. If only /wp-json is failing, the homepage loads and an uptime monitor reports green the entire time a headless front end or mobile app is silently broken.
  • Guessing at the wrong log burns the outage. Restoring .htaccess when the real fault is a PHP fatal, or reinstalling plugins when it is a broken rewrite rule, fixes nothing. It just consumes the minutes before someone escalates.
  • Admin-only 500s trap you outside the fix. When the failure is in code that only loads in wp-admin, you cannot log in to disable the plugin causing it, so the recovery path becomes SFTP and file renames under pressure.
  • A database-caused 500 looks like a code bug. A dropped connection or schema mismatch produces the same blank 500 as a PHP fatal, so without checking the database's log first, you spend the triage window reading PHP code that was never the problem.

3. Why It’s Hard to Spot

A 500 status code tells the browser almost nothing on purpose. Production WordPress hides error detail from visitors, so the same blank page covers a PHP fatal, a web server misconfiguration, a database outage, and a request that never reached PHP.

The cause can also live in one of three logs: PHP's error log (or wp-content/debug.log), the web server's error log (Apache, Nginx, PHP-FPM), or the database's error log. Nothing on a typical host correlates the three.

Scope is the clue most people skip. Someone who hits a 500 on one page reloads a few times, checks the homepage, sees it work, and concludes "the site is fine" without noticing whether it is one page, one section, or one path prefix like /wp-json. That pattern tells you which log to open first.

The REST case is easiest to miss. A fatal inside a REST callback does not produce a white screen, because the HTML-rendering routes never touch the broken code path. All that fails is the JSON response from /wp-json/..., which most people never look at unless something downstream, a form, an app, a data-fetching block, visibly stops working first.

4. Cause

The wp_php_fatals_total signal fires whenever a request dies with an uncaught PHP error, and most WordPress 500s trace back to exactly that. But not all of them: a meaningful share of 500s never touch PHP, and knowing which is which starts with scope.

  • Every page fails. Either a global PHP fatal (a must-use plugin or core file loaded on every request), a broken .htaccess or rewrite block that stops requests before PHP runs, memory exhaustion common to every page, or an upstream / PHP-FPM timeout. Only the fatal and the memory case produce wp_php_fatals_total. The other two happen outside PHP entirely.
  • One route fails. Almost always a plugin fatal scoped to the template or handler that route uses, for example a checkout callback. wp_php_fatals_total on a single file and line, reproducible only on that route.
  • Only /wp-json fails. A fatal raised inside a REST route handler or a rest_pre_dispatch filter. WordPress returns a failed JSON response rather than a white screen, so the front end keeps rendering while API calls fail. Shows up as wp_rest_errors_total, usually alongside wp_php_fatals_total, and is the pattern easiest to miss.
  • Only wp-admin fails. A plugin's admin-only code path (a settings page, an admin-ajax handler) is broken. The front end is untouched, but you cannot reach the dashboard to fix it. Still wp_php_fatals_total, scoped to /wp-admin/.
  • A database failure underlies any of the above. A dropped connection or schema mismatch produces wp_db_errors_total first. If the calling code does not check $wpdb's return value, the next line dereferences a null result, promoting into wp_php_fatals_total. The database error is the cause, the fatal is where it became visible.

One exception matters more than any of the above: a 500 with no entry in the PHP error log, the debug log, or wp_php_fatals_total usually means PHP never ran. That points at the web server or .htaccess, not WordPress. See the memory-limit guide if the pattern looks like resource exhaustion instead.

5. Solution

5.1 Diagnose (logs first)

Establish scope before opening a single log. Reproduce the 500 and note which pattern it matches: every page, one route, only /wp-json, or only wp-admin. That determines which log to open first.

1. Confirm scope with direct requests. Don't rely on clicking around. Hit representative URLs directly and record each status.

for url in / /some-page/ /wp-json/wp/v2/posts /wp-admin/; do
  printf '%-28s ' "$url"
  curl -s -o /dev/null -w "%{http_code}\n" "https://example.com${url}"
done

2. Check the PHP error log first for anything scoped to a route, wp-admin, or /wp-json. This is where a fatal shows up if PHP ran at all.

grep -nE "PHP Fatal error" /var/www/wp-content/debug.log | tail -n 20

# if debug.log isn't enabled, the server-level PHP log has it instead
grep -nE "PHP Fatal error" /var/log/php-fpm/error.log | tail -n 20

3. If every page fails and the PHP log is empty, check the web server error log next. This is where a broken rewrite rule or an upstream timeout shows up, where PHP never gets a chance to log anything.

tail -n 50 /var/log/nginx/error.log
tail -n 50 /var/log/apache2/error.log

A global 500 with an empty PHP log and an entry like this in the web server log means the request never reached WordPress:

2026/07/14 09:12:03 [error] 18421#18421: *982 upstream timed out (110: Connection timed out) while reading response header from upstream, client: 203.0.113.44, server: example.com, request: "GET /checkout/ HTTP/1.1", upstream: "fastcgi://unix:/run/php/php8.1-fpm.sock:", host: "example.com"

4. If neither log explains it, check the database's own error log. A dropped connection can skip the PHP log entirely, since WordPress opens the debug log only after the connection attempt succeeds.

# find where MySQL is actually logging, then read the last entries
mysql -e "SHOW VARIABLES LIKE 'log_error';"
tail -n 50 /var/log/mysql/error.log

Across all four steps, the pattern is consistent: wp_php_fatals_total anchors any 500 that PHP actually raised, wp_rest_errors_total anchors ones scoped to /wp-json, and wp_db_errors_total just before either points at the database as the origin.

5.2 Root Causes

Match what you found in 5.1 against these patterns before changing anything.

  • Every page 500s, PHP log has a fatal: a must-use plugin or core file loaded on every request. Signal: wp_php_fatals_total across all routes.
  • Every page 500s, PHP log is empty, web server log shows a rewrite or config error: a broken .htaccess, often introduced by a plugin writing rewrite rules on activation. No wp_php_fatals_total entry, because PHP never started.
  • Every page 500s intermittently, web server log shows upstream timed out: PHP-FPM not responding within the configured timeout, usually a slow query or exhausted worker pool. Also no PHP-side entry.
  • One route 500s, every other page fine: a plugin fatal scoped to that template or handler. Signal: wp_php_fatals_total on a single file and route.
  • Only /wp-json/... 500s, JSON body instead of HTML: a fatal inside a REST callback. Signal: wp_rest_errors_total{status="500"}, typically alongside wp_php_fatals_total.
  • Only wp-admin/ 500s: a plugin's admin-only code path is broken (a settings page, an admin-ajax handler). Signal: wp_php_fatals_total scoped to admin requests.
  • Any scope, PHP log shows a null-dereference on a $wpdb call and the database log shows a dropped connection or unknown-column error: schema drift or a database outage. Signal pair: wp_db_errors_total (error_code="1054" for a missing column, or "MySQL server has gone away") followed by wp_php_fatals_total.

5.3 Fix

The fix follows from which log had the entry. Do not apply a global fix, memory limit, plugin deactivation, to a scoped failure: it will not touch it.

Global PHP fatal. Identify the file and line from the PHP log, then disable only that plugin or must-use file rather than everything.

wp plugin deactivate broken-plugin
# or, without WP-CLI
mv wp-content/plugins/broken-plugin wp-content/plugins/broken-plugin.off

Broken .htaccess or rewrite config. Restore the default WordPress block, then let WordPress regenerate rewrite rules rather than hand-editing it back.

# restore the default WordPress rewrite block, then flush cleanly
wp rewrite flush --hard

Upstream / PHP-FPM timeout. Almost never solved by raising the timeout further. Find the slow query or blocking call causing workers to hang. See the memory-limit guide if the slowness correlates with memory pressure rather than a query.

One-route or admin-only fatal. Fix the plugin at the file and line the log named. If it followed a recent update, rolling that plugin back is usually faster and safer than patching the crash line, since patching the symptom often just moves the fatal one call deeper.

REST-only fatal. Same as any plugin fatal, fix or roll back the plugin owning the callback, but verify against the endpoint directly, since the front end never showed the failure in the first place.

curl -s https://example.com/wp-json/wp/v2/posts | head -c 300

Database-caused fatal. Restore the database connection or run the pending schema migration first, often a plugin's own update routine that did not complete. Separately, get the calling plugin to check $wpdb's return value instead of assuming success. The connection issue is the cause, but the unguarded null dereference is what turns it into a fatal.

5.4 Verify

A 500 is fixed when the relevant signal goes flat for every scope it appeared in, not when one manual reload returns 200.

# re-check every scope you tested in 5.1, not just the one you fixed
for url in / /some-page/ /wp-json/wp/v2/posts /wp-admin/; do
  printf '%-28s ' "$url"
  curl -s -o /dev/null -w "%{http_code}\n" "https://example.com${url}"
done

# confirm no new fatals since the fix
grep -nE "PHP Fatal error" /var/www/wp-content/debug.log \
  | awk -F']' '$1 > "[14-Jul-2026 10:00:00 UTC"'

For a route or admin-scoped fix, watch wp_php_fatals_total stay flat on that path across a normal traffic cycle, not just your own reload. For a REST-scoped fix, confirm wp_rest_errors_total returns to baseline, since the front end looks fine either way and tells you nothing. For a database-caused fatal, confirm wp_db_errors_total drops back to its normal floor too, most sites have a small nonzero baseline of transient errors, so watch for a return to that floor rather than an exact zero.

6. How to Catch This Early

Most 500s are findable in minutes once you know which log applies. The part that costs time is not knowing you have one at all, the scoped kind that hides behind a homepage that still loads.

This issue surfaces as wp_php_fatals_total.

Default WordPress has no mechanism that tells you a 500 happened unless you personally load the affected page. An uptime monitor pinging the homepage says nothing about /wp-json, and nothing pings wp-admin by default. A REST-scoped fatal can run for days, quietly failing a headless front end while every page a human visits keeps rendering.

Counting fatals, REST failures, and database errors as signals, rather than waiting for someone to report a broken page, closes that gap. A step up in wp_php_fatals_total on one route the moment a plugin updates tells you where and when. The same is true for wp_rest_errors_total, often the only record a REST-scoped failure ever happened.

Watching these as ongoing counts, rather than treating a 500 as a one-off support ticket, turns "the API has been broken since Tuesday and we just noticed" into "the API broke at 14:02 on Tuesday and here is the request that did it."

7. Related Silent Failures

  • wp_rest_errors_total surge: a fatal scoped to /wp-json only, invisible to anyone browsing the site normally.
  • wp_db_errors_total precursor: a dropped connection or schema mismatch that shows up first in the database's own log, before it promotes into a PHP fatal.
  • wp_php_warnings_total spike: a flood of warnings naming a symbol, often the last audible step before that code path produces the fatal behind a 500. Covered in the PHP warning spike guide.
  • wp_php_fatals_total with no preceding update: the same signal, caused by a latent conflict rather than a recent change. The fatal error guide covers isolating the exact line and symbol.
  • A blank page with no 500 at all: the white screen of death, a related pattern where the fatal is suppressed rather than surfaced as a status code. See the white screen of death guide.

See what's actually happening in your WordPress system

Connect your site. Logystera starts monitoring within minutes.

Copyright © 2026 Logystera. Operated by 1969730 Ontario Inc., an Ontario, Canada corporation. All rights reserved.