In this article
One ordinary afternoon I logged into WordPress admin, clicked Plugins, and got a blank white page. No error. No warning. Just the very unhelpful silence of the White Screen of Death.
The strange part was that every other admin page worked fine. Dashboard, Posts, Settings, Media, all of them loaded. Only wp-admin/plugins.php was dead.
Environment: Laragon locally, PHP 8.3, WordPress 6.9.4. Installed plugins: Perfmatters, Rank Math SEO, Post SMTP, LuckyWP Table of Contents.
This post is the whole trail, from opening debug.log to landing on the exact line that crashed. The valuable part is not the fix — the fix is one line — but the method for narrowing things down without guessing.
Step 1: debug.log said nothing, and that was already a clue
The first reflex anyone has with a WSOD is to open debug.log:
// wp-config.php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true); // flipped from false to true
Result: debug.log was completely empty. Turning WP_DEBUG_DISPLAY on gave me the same blank page with nothing printed.
So I tried forcing PHP to speak up, right at the top of wp-config.php:
ini_set('display_errors', 1);
error_reporting(E_ALL);
Still nothing.
This is the point where it is easy to get annoyed and move on, but it is actually the single most important piece of information in the whole story. Fatal errors, parse errors, uncaught exceptions — PHP logs all of them before it dies. A process that dies without managing to write anything at all is almost certainly not a normal PHP error. It is a segmentation fault: the process gets killed down at the C level, and the engine never gets a chance to run the handler that would write the log.
From that moment I knew there would be no PHP log to read. If I wanted a trail, I would have to create one myself.
Step 2: does PHP even reach the file?
Before digging deeper, I needed to rule out something basic: maybe the web server was broken and PHP was never invoked at all.
// first line after <?php in wp-admin/plugins.php
echo "TEST123"; die();
The browser printed TEST123. So PHP runs, and it reaches the right file. The crash happens somewhere later, during the rest of the request.
Step 3: cut the problem in half
This is the most important step methodologically. With no log available, I make my own log out of echo statements. And rather than scattering them everywhere, I bisect.
plugins.php starts by loading admin.php. I put a marker on each side of it:
// wp-admin/plugins.php
echo "BEFORE-ADMIN";
require_once __DIR__ . '/admin.php';
echo "AFTER-ADMIN";
Result: only BEFORE-ADMIN appeared. AFTER-ADMIN never did.
One binary answer, and half the search space is gone: the crash is inside admin.php.
Step 4: drop markers through admin.php
Same technique, finer granularity. wp-admin/admin.php is a very readable sequence of bootstrap steps, so markers at each milestone are enough:
// wp-admin/admin.php — markers at the main milestones
require_once ABSPATH . 'wp-load.php';
echo "A1-AFTER-WP-LOAD "; // WordPress is loaded
require_once ABSPATH . 'wp-admin/includes/admin.php';
echo "A2-AFTER-ADMIN-INCLUDES "; // admin functions are loaded
auth_redirect();
echo "A3-AFTER-AUTH "; // past authentication
// … other steps …
do_action('admin_init');
echo "A7-AFTER-ADMIN-INIT "; // past admin_init
set_current_screen();
do_action("load-{$pagenow}"); // $pagenow = 'plugins.php'
echo "A8-AFTER-LOAD-PAGE "; // past load-plugins.php
The browser returned:
A1-AFTER-WP-LOAD A2-AFTER-ADMIN-INCLUDES A3-AFTER-AUTH ... A7-AFTER-ADMIN-INIT
It stops dead at A7. A8 never arrives. The crash sits inside exactly this:
do_action("load-plugins.php");
And this is where everything clicked. load-{$pagenow} is a dynamic hook that only fires on its own page. The Posts screen fires load-edit.php, Settings fires load-options-general.php, and the Plugins screen fires load-plugins.php. That explains the weirdest symptom completely: why only the Plugins page died while every other screen was fine.
A symptom of "only one page is broken" almost always leads back to a hook that only runs on that page.
Step 5: who hooks into load-plugins.php?
Searching core, the answer is in wp-includes/update.php:
// wp-includes/update.php
add_action('load-plugins.php', 'wp_update_plugins');
add_action('load-update.php', 'wp_update_plugins');
add_action('load-update-core.php', 'wp_update_plugins');
WordPress calls wp_update_plugins() automatically every time you open the Plugins page, to check whether any installed plugin has a new version available.
So I pulled the hook off:
// temporarily added to functions.php to test
add_action('admin_init', function () {
remove_action('load-plugins.php', 'wp_update_plugins');
});
The Plugins page came back to life. So wp_update_plugins() is where the crash happens.
That is already enough for a patch. It is not enough to understand the problem, though, because wp_update_plugins() is a core function that runs on millions of sites without incident. Stopping here means blaming core and never finding out what is actually wrong.
Step 6: inside wp_update_plugins()
So I bisected again, this time within the body of the function. I set up a throwaway AJAX endpoint that replays each step with logging in between:
// temporary test endpoint
add_action('wp_ajax_test_update_plugins', function () {
error_log('STEP 1: Getting plugin data...');
$plugins = get_plugins();
error_log('STEP 1: OK - ' . count($plugins) . ' plugins');
error_log('STEP 2: Calling API...');
$url = 'http://api.wordpress.org/plugins/update-check/1.1/';
$response = wp_remote_post($url, [/* ... */]);
error_log('STEP 2: HTTP ' . wp_remote_retrieve_response_code($response));
error_log('STEP 3: Processing response...');
$updates = new stdClass;
// … process the response …
error_log('STEP 3: OK');
error_log('STEP 4: Saving transient...');
set_site_transient('update_plugins', $updates);
error_log('STEP 4: OK');
wp_send_json_success('Done');
});
Steps 1, 2 and 3 all passed. Step 4 crashed, at set_site_transient('update_plugins', $updates).
Which makes no sense on the face of it. How does saving a transient kill a process? Calling out to an API across the internet was fine, but writing to the database was fatal.
Step 7: the real culprit
It makes no sense until you remember that set_site_transient() does not only write to the database. Before it writes, it runs the pre_set_site_transient_{$transient} filter — here, pre_set_site_transient_update_plugins. In other words, that one "save a transient" line is really an open invitation for every plugin on the site to run its own code.
So I listed who was waiting in that queue:
// Debug: list every callback attached to this filter
global $wp_filter;
if (isset($wp_filter['pre_set_site_transient_update_plugins'])) {
foreach ($wp_filter['pre_set_site_transient_update_plugins'] as $priority => $callbacks) {
foreach ($callbacks as $id => $callback) {
error_log("Filter callback: priority={$priority}, id={$id}");
}
}
}
Three plugins were hooked in:
- Perfmatters — the EDD Software Licensing updater, which validates a license key before allowing updates
- Post SMTP — the Freemius SDK, with its own licensing and update system
- Rank Math SEO — its own update check against a private server
Stripping them all out and saving again:
remove_all_filters('pre_set_site_transient_update_plugins');
set_site_transient('update_plugins', $updates);
// Works fine. No crash.
So one (or more) of those three callbacks kills PHP at the exact moment WordPress tries to store the result of the update check.
Worth spelling out: WordPress core is entirely innocent here. It is just the stage where third-party plugin code was invited to perform.
How does PHP code kill the whole process?
This part deserves spelling out, because "a plugin callback segfaults PHP" sounds absurd if you stop there. Plain PHP code cannot segfault on its own. $a = $b, foreach, if never kill a process. So how does a callback written in PHP manage to kill PHP itself?
Because a request runs across three layers:
| Layer | What it is | Written in |
|---|---|---|
| Userland | Plugin, theme and WordPress core code | PHP |
| Zend Engine + extensions | What actually executes PHP opcodes, plus curl, openssl, json… | C |
| Operating system | Allocates memory, sends signals | — |
The machinery that writes debug.log lives in layer 2. When layer 1 raises a fatal error or throws an exception, layer 2 is still perfectly healthy: it catches the problem, runs the error handler, writes the log, and shuts down cleanly. That is why every ordinary PHP error leaves a trace.
A segfault is the opposite: layer 2 itself touches invalid memory, the operating system fires SIGSEGV, and the process dies instantly. The error handler that would have written the log lives inside the process that was just killed. It never gets to run. That is the entire reason debug.log was empty back in Step 1.
Put differently, PHP userland is only the fuse; the explosion happens down in C. The three most common ways PHP code lights that fuse:
Runaway recursion. Every PHP function call consumes a frame on the C stack. memory_limit governs PHP's heap and has no say over the C stack, so PHP does not report "out of memory" — it simply dies. This one fits the scenario here suspiciously well: set_site_transient() fires the filter, a callback triggers yet another update check, and the loop closes on itself.
Serialising a structure that is too deep. set_site_transient() has to serialise $updates before writing it to the database, and PHP's serialiser is recursive C code. Those three filters are exactly what stuffed extra data into $updates moments earlier. An object graph that nests too deeply, or contains a circular reference, sends the serialiser recursing until the stack runs out.
A faulty C extension. The callback reaches for curl/openssl to talk to a license server. A bug in the extension, or one compiled against a mismatched PHP ABI, dies inside the extension itself.
Confirming it really is a segfault
Step 1 only inferred a segfault from the absence of logs. There is a way to confirm it outright, and it is an easy one to miss: an empty WordPress debug.log does not mean there are no logs left. The web server is the parent process, and it watches its children die:
# Apache
[core:notice] child pid 12345 exit signal Segmentation fault (11)
# PHP-FPM
WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV)
Once you see that line, there is nothing left to guess about. Next time a WSOD leaves debug.log silent, open the Apache or PHP-FPM log before doing anything else.
As for exactly which callback, by which mechanism — this post never chased that all the way down. What it did prove is that removing those three filters stops the crash. My prime suspect is an older licensing SDK build (EDD, Freemius) that is not yet happy on PHP 8.3, but that remains a suspicion rather than a conclusion. Confirming it would mean attaching xdebug or reading a core dump, and that is a different post.
The fix: patch from the theme, not core or the plugins
The tempting move at this point is to open up the plugin code and fix it there. Don't. That edit gets wiped on the next update, and whoever debugs it next will have no idea why the site started dying again.
The clean approach is to remove the specific hook that crashes, from the theme:
// theme: inc/classes/class-admin.php
/**
* Fix: wp_update_plugins() triggers the pre_set_site_transient_update_plugins
* filters from Perfmatters/Post SMTP/Rank Math, which segfault PHP.
* This only disables the realtime check when opening the Plugins page;
* the cron update check still runs normally.
*/
public function fix_plugins_page_crash()
{
remove_action('load-plugins.php', 'wp_update_plugins');
}
Wired up on admin_init:
add_action('admin_init', [$this, 'fix_plugins_page_crash']);
Why is this safe? Because load-plugins.php is not the only thing that calls wp_update_plugins(). WordPress also has a cron job that runs it on a schedule, every 12 hours by default. Removing the load-plugins.php hook only disables the check that fires at the moment you open the Plugins page; update information still gets refreshed by cron. You trade a few hours of freshness for a Plugins page that opens.
Write down the reason in a comment, as above. A bare remove_action() with no explanation is exactly the kind of thing someone deletes six months from now because "it doesn't seem to do anything," and then the site dies all over again.
Symptom lookup table
| Symptom | Likely cause |
|---|---|
WSOD, debug.log contains a fatal error | An ordinary PHP error — read the log |
WSOD, WP_DEBUG and display_errors on, log still empty | Segfault; cross-check the Apache/PHP-FPM log to confirm |
| Only one admin page dies, the rest are fine | The load-{$pagenow} hook for that specific page |
| The Plugins or Updates screen dies | Almost certainly wp_update_plugins() and the premium plugins' filters |
An echo at the top of the file prints nothing | PHP is never reached — check the web server |
Checklist for a WSOD on one specific admin page
- Turn on
WP_DEBUGanddisplay_errors. If no error shows up at all, suspect a segfault. - Open the Apache / PHP-FPM log and look for
exit signal Segmentation fault (11)to confirm it. - Add
echo+die()at the top of the file to confirm PHP reaches it. - Drop markers and bisect to narrow down the crashing line.
- Identify which hook is running at the crash point.
- List every callback attached to that hook.
- Remove them one at a time to find the real culprit.
- Patch with
remove_action()/remove_filter()from a theme or mu-plugin.
Four things I took away
No log is itself information — but don't forget there are logs elsewhere. When everything is turned on and PHP still says nothing, that is not a dead end, it is the answer: the process is being killed down in C, before it can run the handler that writes the log. But don't stop at the conclusion "so there are no logs to read." An empty debug.log only means PHP could not write one; the web server still records the death of its child process, and opening that gives you confirmation instead of inference.
Bisecting always beats guessing. Every well-placed marker cuts the search space in half. The seven steps here went from "all of WordPress" down to a single function call. Guessing guarantees nothing; bisecting gets there in O(log n) steps.
A hook is where someone else's code runs inside yours. set_site_transient() looks like a harmless database write, but it opens the door to every plugin on the site. When a crash lands on an innocent-looking core call, the move is to list what is hooked into it, not to suspect core.
Don't stop at the first working patch. By step 5 I had one line that brought the Plugins page back. Stopping there would have given me the conclusion "WordPress core is buggy" — completely wrong. Two more steps surfaced the real culprit: the premium plugins' update checkers. A patch that works but rests on a wrong diagnosis will eventually turn on you.
This entire post boils down to a single remove_action(). But to write that one line without worrying, I needed to know exactly what it switches off and what it costs.
About this post. This is a real case study, not a worked example built to illustrate a point. The bug happened on my own machine, and the investigation above is what I actually did to find the cause and put it to rest. The environment, the plugin names and the order of the steps are all kept as they happened.
The prose and structure were drafted with AI assistance to make the writing clearer. The section explaining the C-layer mechanism, and the tip about confirming a segfault through the web server log, were added during editing, to answer the "how can PHP code kill the whole process" question. The bug, the debugging and the final patch are mine, and that patch runs on a real site.
Thanks for reading all the way down. There is no comment section on this site yet, so if you have feedback, a question, a counter-argument to any of the conclusions, or a stranger WSOD of your own, email me at info@nguyenlap.net or reach me on Facebook.