On a WordPress site I maintain, a category chip was supposed to read "BANKING & NEOBANKS". What actually rendered on screen was this:
BANKING & NEOBANKS
The literal text &, sitting right in the middle of the label. Not a rendered & character — five actual characters: ampersand, A, M, P, semicolon. It looks like random encoding corruption, but it's perfectly logical once you know the cause.
The markup was simple — a chip that uppercases the term name:
<span class="chip">
<?php echo esc_html( strtoupper( $term->name ) ); ?>
</span>The category name is "Banking & Neobanks". So why does & come out as &?
Root cause: term names are already encoded in display context
This is the part that trapped me. When WordPress hands you a term field in 'display' context (which is what happens through get_the_terms, wp_get_post_terms, and whenever a term is accessed in a normal loop), it runs that field through sanitize_term_field. And in 'display' context, that sanitizer encodes & into &.
That means $term->name for "Banking & Neobanks" is not the string you think it is. The actual value in memory is:
Banking & Neobanks
Now follow the chain. If you just esc_html and print it, everything's fine — the browser receives & and decodes it back to &. That's exactly why naive-looking code so often "happens to be correct."
But the moment you slip in strtoupper, it uppercases the whole string — entity included:
Banking & Neobanks
strtoupper ↓
BANKING & NEOBANKS
And & is not a valid HTML entity. Entities are case-sensitive; the valid one is & (lowercase). Because & is unrecognized, the browser doesn't decode it — it prints it verbatim. That's why the literal text shows up in the chip.
Why CSS text-transform is safe but PHP/JS isn't
I briefly considered just uppercasing in CSS, and that does dodge this bug — for a reason worth understanding:
.chip { text-transform: uppercase; } /* safe */text-transform: uppercase operates on already-rendered text. By the time CSS runs, the browser has already decoded & into &, so what gets uppercased is the & character — not the entity. The effect is purely visual, and correct.
But strtoupper (PHP) and toUpperCase() (JS) operate on the raw string before the browser decodes anything. They see & and dutifully uppercase it into &. That's the trap: a case function at the code layer breaks the entity, a case function at the presentation layer doesn't.
The fix: decode entities BEFORE transforming case
The fix is to decode the entity back to its raw character first, then uppercase:
<span class="chip">
<?php echo esc_html(
strtoupper( wp_specialchars_decode( $term->name, ENT_QUOTES ) )
); ?>
</span>Now the chain is correct:
wp_specialchars_decode( $term->name, ENT_QUOTES )turnsBanking & Neobanks→Banking & Neobanksstrtoupper( ... )turns it →BANKING & NEOBANKSesc_html( ... )re-encodes&→&, safe for output- The browser decodes
&→&at render time
Final result on screen: BANKING & NEOBANKS. Exactly what you wanted.
The same trap applies to strtolower, ucfirst, and mb_strtoupper. Any PHP case function — or toUpperCase/toLowerCase in JavaScript — will mangle the entity if you run it over the raw term string.
Lesson
WordPress term names come HTML-entity-encoded in 'display' context. $term->name for "Banking & Neobanks" is actually "Banking & Neobanks". esc_html alone is fine because the browser decodes the entity back — but the instant you run strtoupper/strtolower over it, & becomes an invalid & and leaks out as literal text.
The rule: never run a PHP/JS case function over a term name without wp_specialchars_decode( ..., ENT_QUOTES ) first. If all you need is an uppercase look, text-transform: uppercase in CSS is the safest route — it works on already-decoded text and never touches the entity.
