Some bugs announce themselves with a stack trace. The best ones are invisible — three bytes you can't see in your editor, can't see in cat, and can't see in a diff, quietly corrupting every JSON response your site produces. This is the story of one of those, how we tracked it down on a production WordPress Multisite, and exactly how to find, fix, and prevent it on your own infrastructure.
The symptom
It started where these things usually do: WordPress Site Health. The dashboard flagged a REST API problem that shifted its wording as we worked — first "The REST API encountered an unexpected result" with a 503, and later the subtler "The REST API did not process the context query parameter correctly."
That second message is the interesting one, because on the surface everything looked fine. The public REST API returned clean 200s. The block editor loaded. Anonymous requests to /wp-json/wp/v2/types/post came back as valid JSON. Yet Site Health insisted the API was misbehaving.
If you've seen this message, you've probably been sent down a rabbit hole of loopback and authentication theories. We went down a few ourselves (a stale /etc/hosts entry was genuinely misdirecting the server's loopback — a separate story). But the REST warning survived every one of those fixes. Here's how we finally cornered it.
The decisive test: internal vs. the wire
The breakthrough was separating what WordPress computes from what travels over HTTP.
First, we dispatched the exact request inside WordPress, bypassing the network entirely:
wp_set_current_user( $admin_id );
$req = new WP_REST_Request( 'GET', '/wp/v2/types/post' );
$req->set_param( 'context', 'edit' );
$res = rest_do_request( $req );
$data = $res->get_data();
// capabilities: PRESENT
capabilities was present. Core, plugins, and theme were all producing the correct response. So whatever was wrong happened on the wire, between WordPress generating the body and json_decode() parsing it on the other end of the loopback.
So we captured the raw bytes of the authenticated loopback response and looked at them with xxd:
$ head -c 16 /tmp/rest_body.bin | xxd
00000000: efbb bfef bbbf efbb bfef bbbf efbb bfef ................
There it was. EF BB BF, repeated, before the opening {. The response body was valid JSON with a pile of UTF-8 Byte Order Marks glued to the front. json_decode() choked, returned null, and WordPress — seeing a 200 with an unparseable body — reported it as the context parameter not being processed. The data was perfect. The container was contaminated.
What a BOM actually is, and why it wrecks PHP
A Byte Order Mark is the Unicode code point U+FEFF. In UTF-16 it signals endianness; in UTF-8 it's encoded as the three bytes EF BB BF and is entirely unnecessary — UTF-8 has no byte order to mark. It exists in UTF-8 files mostly because some editors add it by default when you "Save as UTF-8."
For HTML, a leading BOM is usually harmless — three invisible bytes before <!DOCTYPE> that browsers quietly tolerate. For PHP, it's a landmine. PHP only treats text as code inside <?php … ?> tags. Anything before the opening <?php is output, echoed verbatim to the response, exactly like inline HTML. A PHP file that begins with a BOM therefore emits those three bytes every time it loads, before a single line of your code runs.
Now scale that to a theme. Our culprit was a custom theme whose functions.php loaded a couple dozen partials — shortcode handlers and template parts — on every request. Roughly 28 of those files had been saved with a BOM. Each one contributed its three bytes to the top of the output buffer. On an HTML page: invisible, ignored. On a REST response that must begin with {: fatal.
This is also why a stray BOM classically breaks header() calls ("headers already sent"), feeds, sitemaps, AJAX handlers, and anything else that must control its own output from the first byte.
Why it was so hard to see
Three things conspired to make this a nasty hunt:
- It's invisible. BOMs don't render in editors, don't show in
cat, and don't appear in most diffs. Only a hex view reveals them. - It was intermittent. A page cache sat in front of the site. Depending on cache state and which version of a file had last been deployed, a given loopback sometimes returned clean and sometimes corrupted. A single "it works now" test proved nothing.
- The error message points the wrong way. "Did not process the
contextparameter" sends you hunting through authentication and REST internals, when the real problem is raw bytes the REST layer never even sees as its own.
The lesson we keep relearning: when a 200 carries a body that won't parse, stop trusting the application layer and look at the bytes.
How to detect it
Scan your plugins and theme for PHP files that begin with a BOM. grep -P is unreliable on binary byte patterns, so we use Python, which is exact:
python3 - <<'PY'
import os
root = "/var/www/html/your-site"
for dp, _, fs in os.walk(root):
if "/vendor/" in dp: # skip test fixtures that use BOMs on purpose
continue
for f in fs:
if f.endswith((".php", ".inc")):
p = os.path.join(dp, f)
try:
if open(p, "rb").read(3) == b"\xef\xbb\xbf":
print(p)
except Exception:
pass
PY
To confirm the symptom end-to-end, dispatch the real Site Health REST test from WP-CLI and inspect the body's first bytes — if you see efbbbf, you've found your gremlin.
How to fix it
Strip the leading three bytes from each offending file — safely, in place, skipping anything under vendor/:
python3 - <<'PY'
import os
root = "/var/www/html/your-site"
for dp, _, fs in os.walk(root):
if "/vendor/" in dp:
continue
for f in fs:
if f.endswith((".php", ".inc")):
p = os.path.join(dp, f)
b = open(p, "rb").read()
if b[:3] == b"\xef\xbb\xbf":
open(p, "wb").write(b[3:])
print("stripped:", p)
PY
Then reload PHP-FPM to clear OPcache (sudo systemctl reload php-fpm) and flush any page cache, since a cached copy of the corrupted response will happily keep serving. Re-run your REST check; the body will parse and Site Health will clear.
One caution: stripping files on a live server is a stopgap. If your deploy pipeline overwrites the theme, the BOMs come right back on the next release. The real fix is upstream.
How to prevent it for good
Fix it at the source — in the repository and the editor — so it can never be deployed again.
Add an .editorconfig to the project root so every contributor's editor saves UTF-8 without a BOM:
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
Set your editor explicitly: in VS Code the active encoding shows in the status bar — choose "Save with Encoding → UTF-8", not "UTF-8 with BOM." Then make it impossible to merge a regression by adding a CI or pre-commit check that rejects any PHP file beginning with a BOM:
# fails if any tracked PHP file starts with EF BB BF
! git grep -lI $'\xef\xbb\xbf' -- '*.php' | grep -q .
Belt and suspenders: config enforces the default, the hook catches the mistake, and nobody loses an afternoon to three invisible bytes.
When "it works everywhere except here" is the whole problem
What made this worth writing up isn't the fix — stripping a BOM is one line. It's the diagnosis. The failure presented as an authentication problem, lived intermittently behind a cache, and was invisible to every normal tool. Finding it meant methodically ruling out the network, isolating the application layer from the transport, and finally dropping down to the raw bytes. That discipline — separating layers until the real one confesses — is what turns a multi-day mystery into a one-line fix.
Fighting a bug that only makes sense at the byte level?
This is the kind of work Tierney Dynamics does every day: untangling WordPress, PHP, and AWS infrastructure problems that defy the usual playbook. We handle production incident response and root-cause analysis, WordPress Multisite and performance tuning, AWS architecture (ALB, WAF, EC2, RDS), CI/CD hardening, and the preventive engineering that stops the same class of bug from coming back.
If your site is misbehaving in ways your team can't reproduce — or you'd rather have someone put the guardrails in place before it does — get in touch with Tierney Dynamics. We'll find the three bytes, and we'll make sure they never ship again.
Comments