Zero bytes. Not an error page, not a white screen with a message on it, not a 500. The status line said 200 OK, Content-Length was zero, and the body was empty all the way down to a last byte that never arrived. Every URL behaved that way, front page and deep pages alike, and the PHP error log was as clean as if nothing had touched it all day.
The situation was already hot when I got there. Someone else had run a file cleanup on the server a few hours earlier, and the site had been blank ever since. A conclusion had formed on its own in the conversation, and I believed it for a while too: something must have been deleted. The theme, wp-includes, something. It looked exactly like an install that had been stripped.
The one thing that did not fit that story was the shape of the output. An install with a missing theme still emits something, either a wp_die page saying the theme directory is gone, or the default theme if one is still present. Zero bytes is a different animal. Zero bytes means nothing ever rendered at all.
The shell gave me the same answer, and that threw away half the suspects
Before guessing any further, I ran the front file straight from the shell, in the site's own directory, with no web server involved at all.
php index.php | wc -cZero. Exactly what Apache had been serving.
That single command eliminates an enormous amount of the search space. If the empty body only appeared through the web server and not on the CLI, the suspect list would be long and tedious: rewrite rules, .htaccess, output buffering being swallowed, a cache plugin serving an empty file, a proxy or CDN in front, headers cut somewhere in transit. The moment the CLI returned the same zero, none of those layers mattered. PHP itself was producing nothing, and Apache was faithfully forwarding that nothing, 200 included, because nothing had actually failed.
So it was inside PHP, in something that stopped before printing anything at all, without ever considering itself in the wrong.
Booting WordPress by hand, and finding the site alive
The next step ran from the same directory, still read-only, still without touching a single file. I tried to boot WordPress myself, in stages, starting from the lowest one.
php -r 'require "/path/to/docroot/wp-load.php"; echo get_bloginfo("name"), " | ", $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->posts}");'It worked perfectly. The site name came back, the database connection was live, the post count was sensible. That meant wp-config.php was intact, the credentials were right, wp-settings.php ran to completion, and the plugins loaded without a fatal. The supposedly stripped install turned out to be in good shape to a fairly deep level.
Then one stage higher, the path that actually renders a page:
php -r 'define("WP_USE_THEMES", true); require "/path/to/docroot/wp-blog-header.php";' | wc -c175,528.
A hundred and seventy-five thousand bytes of complete HTML, from the same docroot, the same theme, the same database. I checked the tail and found exactly one </html> in there, so this was not a coincidentally long fragment but a page that had rendered all the way to the end. The site everyone had written off was capable of rendering itself in full, right that second, as long as it was called correctly.
At that point the search space had shrunk from thousands of files to one. Everything behind the front door worked. The only thing that did not was the front door.
What was actually in index.php: a guard that cancels itself
<?php
if ( ! isset( $wp_did_header ) ) {
$wp_did_header = true;
require_once __DIR__ . '/wp-blog-header.php';
}Read quickly, this looks like a more careful version of the stock index.php. There is a guard so the bootstrap does not run twice, and a require_once that sounds safer than a plain require. It looks like the work of someone trying to fix something.
The problem is that wp-blog-header.php opens with exactly the same condition. Here is its shape in core, with only the doc comments stripped out:
<?php
if ( ! isset( $wp_did_header ) ) {
$wp_did_header = true;
require_once __DIR__ . '/wp-load.php';
wp();
require_once ABSPATH . WPINC . '/template-loader.php';
}The entire body of that file sits inside the guard. There is not one statement outside it.
So the sequence goes like this. index.php checks $wp_did_header, does not find it, and enters. index.php sets $wp_did_header = true. index.php requires wp-blog-header.php. That file gets loaded, executes from its first line, checks $wp_did_header, and finds it already on. The condition is false, the block is skipped, the file is done. WordPress never loads at all.
require_once does nothing to help here, and it misled me for a moment. The file really was loaded and really was executed, so this is not the "already included, therefore skipped" case. What happened is much quieter: the file ran obediently, read a condition written on its own first line, and concluded that it had no work to do. Nothing failed. PHP had no reason to complain. The script exited zero, printed nothing, and Apache answered 200 with an empty body because that is honestly what came out.
There was a second break hiding behind the first one. That rewritten version had also lost define( 'WP_USE_THEMES', true ). That constant is what tells the bootstrap path that this request should be rendered into a page through the theme, rather than merely loading WordPress into memory. Had the guard been fixed while the constant stayed missing, the site would still have been blank, just through a different mechanism, and the next round of debugging would have started from scratch.
Restore index.php to two statements, and do not ship it first
The correct shape carries no guard at all, and does not need one:
<?php
define( 'WP_USE_THEMES', true );
require __DIR__ . '/wp-blog-header.php';That is the whole file. Guarding against double execution is not index.php's job and never was.
More important than the content of the patch is when the patch goes in. There was another pending server config change in this case, a docroot pointing at the wrong directory. The temptation to fix that one first is strong, because it looks bigger and explains more. But as long as index.php in the correct docroot still cancels itself, restoring the docroot first moves the site from serving stale content to serving a completely blank page. Bad turns into worse, in front of everyone who is already watching.
So the bootstrap first, then the docroot. And before either of them, that read-only manual boot, the one that only counts bytes and changes nothing. The number 175,528 was not just a technical clue. It is what let me say with confidence that nothing had been lost, before touching anything, at a moment when everyone was ready to believe that months of work had evaporated.
The sentinel belongs to the file being called, not to its caller
The rule I took away from this is short. Never set a sentinel variable that belongs to the file you are about to call. A sentinel like that is set BY the destination file, inside itself, as a note that it has already run. Turning it on from the outside beforehand is not caution. It is telling that file a lie about having finished its work. And a file that believes it has already finished does not argue. It goes straight home.
The rest is reading the symptom properly. The combination of 200, a zero-byte body, a clean error log, and an identical result on the CLI is not a broken theme or a plugin conflict. It is the fingerprint of a bootstrap that stopped before its first useful line. These days, when a page comes back empty without complaining about anything, the first thing I do is not open functions.php. It is to run the front file from the shell and count the bytes.