Short answer
A PHP fatal error kills the request before WordPress can render anything, so a
locked-down site shows you two generic sentences and nothing else. The fix is to
stop trying to display the error and start storing it: register a shutdown
function, read error_get_last(), write it to a WordPress option, and pull it
back out through an admin-only REST route on a second request. That gave me the
file and line number in about ten minutes, after an hour of guessing wrong.
The setup wizard that killed the page it was running on
I was hardening this site and installing two-factor authentication. This is the
same WordPress install that runs
the publishing pipeline I wrote about last time,
so it was in daily use and I could not afford to break it. The plugin was WP 2FA
4.1.0. Installation was clean, network activation was clean, and the setup wizard
walked me through the usual steps: pick a method, choose TOTP, confirm.
Then the final step — the one where the QR code appears so you can scan it with
your authenticator app — rendered a blank page with a critical error notice.
That alone would have been annoying. What made it a real problem is what
happened next: the user profile screen kept dying. Every attempt to go back
and undo the half-finished 2FA setup hit the same dead page, because the profile
screen is where WP 2FA renders its settings. The door I came through had locked
behind me.
The rest of the admin was perfectly healthy. Posts, media, plugins, settings —
all fine. Exactly one screen was dead, and it was the screen I needed.
Why the standard debugging advice does not help here
Every search result tells you the same handful of things. Here is what each one
actually did for me.
| Advice | What it assumes | What happened |
|---|---|---|
Turn on WP_DEBUG |
You can edit wp-config.php and it is safe to print errors |
WP_DEBUG on its own prints PHP errors to visitors too. Not acceptable on a live site, even one with no traffic yet. |
| Read the PHP error log | You know where it is and can read it | On a locked-down host, error_log may be unset, pointed at a directory you cannot reach, or discarded entirely. |
| Check the fatal-error email | WordPress emailed you the recovery-mode link | It never arrived. Site email was not configured, which is extremely common on a fresh install. |
| Disable plugins one by one | The failure is a plugin conflict | It was not a conflict. Disabling the plugin removed the symptom and told me nothing about the cause. |
There is another option nobody mentions, and it is the one that worked.
The suspect I was wrong about
My first theory was memory_limit. It fit beautifully: rendering a QR code is
image work, image work is memory-hungry, and 128M is the number everyone blames.
I was ready to raise it and move on.
It was wrong. Worse, I had no way to know it was wrong, because I had no error
to read. I could have raised the limit, watched the page die anyway, and
concluded that the limit needed to be higher still. That is how an afternoon
disappears.
This is the actual lesson of the post, and it has nothing to do with PHP:
When you cannot observe the failure, every fix you try is a guess — and a
guess that fails looks identical to a fix that was not big enough.
So the job was not “fix the page.” The job was “make the failure visible.”
How do you capture a fatal error when you cannot see the log?
A fatal error stops PHP from executing anything further in that request — but
PHP still runs registered shutdown functions on the way out. That is the
opening. The dying request cannot render a page, but it can absolutely finish
one more database write.
So: catch it on shutdown, store it, and read it back from a request that is not
dying.
That order matters beyond this bug. Producing the observable before you start
asking for help is what turns a plausible theory into a stack trace — and it is
the first thing I would tell anyone debugging alongside an agent,
which reasons about whatever you actually hand it.
Put this in a single-file must-use plugin — wp-content/mu-plugins/probe.php,
with an opening <?php — rather than anywhere that depends on the admin UI. The
admin is partly broken, which is the whole problem, and a file you can delete
over SFTP is easier to be sure about than a setting you have to click.
// TEMP probe — delete once you have the answer.
add_action('plugins_loaded', function () {
register_shutdown_function(function () {
$e = error_get_last();
$fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR];
if (!$e || !in_array($e['type'], $fatal, true)) {
return;
}
update_option('probe_last_fatal', [
'time' => gmdate('c'),
'message' => $e['message'],
'file' => $e['file'],
'line' => $e['line'],
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'user' => get_current_user_id(),
], false);
});
});
add_action('rest_api_init', function () {
register_rest_route('probe/v1', '/last-fatal', [
'methods' => 'GET',
'permission_callback' => function () {
return current_user_can('manage_options');
},
'callback' => function () {
return get_option('probe_last_fatal', null);
},
]);
});
Four details in there matter more than they look:
- Filter on fatal error types.
error_get_last()returns the last error of
any kind, including notices and deprecations. Without thein_arraycheck
you will store a deprecation warning and chase it for an hour. - Pass
falseas the third argument toupdate_option. That turns
autoload off, so a debug row does not get loaded into memory on every request
for the rest of the site’s life. - Guard the route with
manage_options. File paths and stack traces are
reconnaissance material. This endpoint must never be readable by an anonymous
visitor. - Read it over REST, not in the admin. The screen you would normally use is
the broken one, so stop trying to use it. Any authenticated request will do,
and reading a debug value is exactly the kind of one-off a script handles
better than a browser.
Trigger the broken page once, then read the result from anywhere:
curl -s -u "USERNAME:APPLICATION PASSWORD" \
https://example.com/wp-json/probe/v1/last-fatal
One warning I can give you from experience, because I walked into it myself:
this stores exactly one fatal, and the next one overwrites it. If there is
any chance the record you already have is the interesting one, read it out and
save it somewhere before you trigger anything else. If you would rather not
think about it, push onto an array instead of replacing a value, and cap the
list at the last five.
What the trace actually said
Uncaught WP2FA_Vendor\BaconQrCode\Exception\RuntimeException:
You need to install the libxml extension to use this back end
in .../plugins/wp-2fa/includes/classes/bacon/bacon-qr-code/
src/Renderer/Image/SvgImageBackEnd.php:40
Read that message again, because it is the whole point of this post.
It is not cryptic. It is not a stack trace you need to interpret. The QR
library — BaconQrCode, vendored inside the plugin — checked whether the PHP
extension it needs was present, found that it was not, and threw an exception
containing a plain-English instruction telling me exactly what to install.
That message existed from the very first time the page died. It was correct, it
was specific, and it was actionable. And I still spent an hour guessing about
memory_limit, because nothing on this server was configured to show it to a
human. The library did its job perfectly. The delivery was what was broken.
Every strange thing about the symptom explains itself at once:
- Only that one screen died, because only that one screen renders a QR code.
- The profile page kept dying, because that is where the plugin puts its
settings. - Nothing appeared in a log, because nothing was configured to write one.
Confirming it took one line, and this is the check worth keeping:
var_dump(extension_loaded('libxml'), class_exists('XMLWriter'));
On Debian and Ubuntu the pieces the QR back end wants — libxml, dom,
xmlwriter, simplexml — all arrive in the same php-xml package, which is why
one install fixed all of them at once.
One detail that costs people time: restart the right thing. This server runs
PHP 8.3 through apache2handler — mod_php, not PHP-FPM — so the extension only
becomes visible to WordPress after Apache itself restarts. Most write-ups tell
you to restart php-fpm, and on a mod_php box that command either fails or
silently accomplishes nothing.
sudo apt install php8.3-xml
sudo systemctl restart apache2
Check which one you are on before you copy anyone’s restart command:
php -i | grep 'Server API'
What the URLs told me for free
Two things fell out of ordinary page source while I was working, and both saved
time.
The uploads path gave away the install type. Media on this site lives under
/wp-content/uploads/sites/7/. That sites/7/ segment only exists on
multisite, and the number is the blog ID. I now knew I was working inside a
network install rather than a standalone site — which changes where a fix has to
be applied.
The plugin endpoint confirmed it. Asking the REST API about the plugin
returned rest_network_only_plugin. WP 2FA is network-activated only; it cannot
be activated per site on multisite. That is not a bug, and if I had tried to
“fix” the problem by toggling activation at the site level, I would have spent
another hour on a door that does not open.
Neither of these was in an error message. Both were sitting in plain sight, one
in a URL and one in an error code.
The part that turned out to be bigger
Once I could see the real problem, the fix was a package install and a restart.
But it raised an obvious question: if the XML extension was missing, what else
was?
The answer was: a lot. And one of the others had been quietly damaging every
image on the site for weeks without producing a single error message anywhere.
That one gets its own post.
What to check before this happens to you
Nothing in this story required debugging skill. It required a server that had
been set up completely, and mine had not.
A stock PHP install is not what WordPress plugins assume. They assume the set a
normal distribution package pulls in, and if your host installed a minimal PHP to
save memory, nothing fails at install time — it fails later, on one screen, in
front of you. So audit first, while nothing is broken and you are calm:
php -m | sort
Then compare against what a WordPress site actually leans on:
| Extension | Something breaks without it |
|---|---|
xml, dom, xmlwriter, simplexml |
QR codes, sitemaps, anything that builds markup |
gd or imagick |
Every generated image size |
mbstring |
Non-ASCII text handling |
curl |
Outbound HTTP: updates, APIs, license checks |
zip |
Plugin and theme installation |
intl |
Locale-aware formatting |
On Debian or Ubuntu that is one line, and it is far cheaper to run it now than to
diagnose it later:
sudo apt install php8.3-{xml,gd,mbstring,curl,zip,intl}
Then restart the right service — see above — and confirm with php -m that the
extensions are actually loaded. Do not assume the install worked because the
package manager exited zero. That assumption is the same one that cost me the
hour in the first place.
FAQ
How do I see a WordPress fatal error when WP_DEBUG is off?
Register a PHP shutdown function that calls error_get_last(), and write the
result to a WordPress option instead of trying to display it. The dying request
cannot render anything, but it can still finish writing to the database. Read the
option back through an admin-only REST route from a second request.
Why does only one WordPress admin page show a critical error?
A fatal error kills the single request that triggers the offending code path, not
the whole site. If one plugin screen calls a class your PHP install does not
have, only that screen dies. The rest of the admin keeps working, which makes the
failure look far stranger than it is.
Can I turn on WP_DEBUG on a live site?
You can, but WP_DEBUG alone prints errors to visitors. Pair it with
WP_DEBUG_DISPLAY set to false and WP_DEBUG_LOG set to a path you can actually
read. If you cannot reach the filesystem, a shutdown handler that stores the last
fatal in the database is the safer route.
Wrapping up
The plugin was never the problem, and neither was memory. A library had already
written the answer down in plain English — install the libxml extension — and
the only thing standing between me and that sentence was a server with nowhere to
put it.
That is worth sitting with, because it generalises past WordPress. Most of the
time you are not missing a diagnosis. You are missing a path from the diagnosis
to your eyes.
If you take one thing from this: when a WordPress page dies and you have nothing
to read, stop trying fixes. Twenty lines of shutdown handler turn a guess into a
file path and a line number, and you can delete it the moment you have your
answer.
Then actually delete it. A route that returns stack traces is not something to
leave lying around, and a debugging shortcut that outlives the bug it was written
for is how sites acquire the kind of quiet weakness that
the pipeline post
was built to avoid: a mechanical step nobody remembers to undo.
This turned out to be the first of five failures on this site that all reported
success in one way or another. Once I could see the shape of it, I wrote up
the pattern behind all five.
Primary sources:
register_shutdown_function
· error_get_last
· XMLWriter
· Debugging in WordPress
· register_rest_route