# PHP strip_tags deleted three quarters of my post over one character

> PHP strip_tags deleted three quarters of a post because of one unescaped angle bracket. The state machine from php-src, and why a single space prevents it.

Source: https://visibleorigin.com/php-strip-tags-one-character/
Published: 2026-09-09

---

**The short version.** I added a word count to this site’s structured data. It reported **1,111 words for an article of 3,742**. The cause was one character in the post: an ASCII arrow, `<-`, inside a block of shell output. PHP’s `strip_tags()` took that `<` for the start of a tag and discarded everything after it, to the end of the document.

**Escaping that single character changed the function’s output from 6,665 bytes to 22,131.** No error, no warning, no exception. And the whole difference between the safe version and the destructive one is a space: `a < b` survives untouched, `a <- b` does not.

This note is about a function almost every PHP codebase calls, including WordPress core, and about the shape of failure it has: not a crash, not a wrong answer you can see, but a quiet subtraction of most of your text. It is also about my getting the explanation wrong twice before getting it right, which turned out to be the more useful half.

## What I was doing when it broke

I was adding `wordCount` to the `BlogPosting` node this site publishes for each post. It is a small, standard property, and the value has to come from the content itself so that it cannot drift from what is on the page. The first version was the obvious one:

```
$words = str_word_count( wp_strip_all_tags( strip_shortcodes( $content ) ) );
```

Then I checked the numbers against the posts, because publishing a claim about my own article without checking it is the thing this site exists to argue against. Four of them looked right. One did not:

```
post 66    3,678 words
post 87    4,602
post 91    1,111        <- the article is 3,742 words
post 95    3,799
```

A word count is a small thing to get wrong. But it was about to go out as machine-readable metadata, stating a fact about my writing to anything that reads the page, and it was off by a factor of three.

## One character

The content of that post is 26,756 bytes. Running the same function over it:

```
strip_tags($content)              26,756 bytes in   →   6,665 out
```

Three quarters of the document, gone. The first thing worth doing with a number like that is not to explain it but to bracket it, and the cheapest bracket is a character census:

```
< in the document: 580
> in the document: 579
```

One more opening bracket than closing. Searching for a `<` that does not begin a tag found exactly one, at offset 7,586, in a block of shell output where I had drawn an arrow to annotate a line:

```
# x-litespeed-cache: miss     <- the first request stores the entry
```

So I escaped that one character, wrote it as `&lt;-`, which renders on the page exactly the same, and ran the function again:

```
original                          26,756  →   6,665
with that ONE < escaped           26,759  →  22,131
```

**Three bytes of change recovered 15,466 characters.** One arrow, in one code block, was destroying seventy-five per cent of the article every time anything asked for its plain text.

![Diagram of the PHP strip_tags state machine: a less-than followed by whitespace is printed as text, anything else enters tag state where further less-than signs increase a depth counter and only an orphan greater-than can exit](https://visibleorigin.com/wp-content/uploads/strip-tags-state-machine.webp)
The scanner, with the line numbers from php-src. The only escape from tag state is a `>` arriving at depth zero — an orphan. Well-formed HTML does not contain one.

## What the function actually does

I read the implementation rather than guess, and I read the exact version this server runs. `php -v` says 8.3.31, so the source is the `php-8.3.31` tag, [ext/standard/string.c](https://github.com/php/php-src/blob/php-8.3.31/ext/standard/string.c), function `php_strip_tags_ex` at line 4799. Four lines carry the whole behavior.

**Line 4803** declares the state, and this is the part that surprised me:

```
int br, depth=0, in_q = 0;
```

`depth` is initialized once, for the entire string, and never reset.

**Line 4836** is the only escape, and it is not in the documentation:

```
if (isspace((unsigned char)p[1]) && !allow_tag_spaces) {
```

If the character after a `<` is a space, a tab or a newline, the `<` is copied to the output as an ordinary character and the scanner stays in text. And `strip_tags()` always reaches this branch, because line 4560 shows it calling the scanner with that flag off:

```
ZSTR_LEN(buf) = php_strip_tags_ex(ZSTR_VAL(buf), ZSTR_LEN(str), allowed_tags, allowed_tags_len, 0);
```

**That single space is the entire difference between the benign case and the destructive one.** `a < b` in a sentence is safe. `a <- b`, `a<b`, `i<3` and `x << 2` are not.

Anything else after the `<` puts the scanner inside a tag, and then **lines 4886 and 4888** decide how it gets out:

```
depth++;                        // another '<' while already inside

case '>':
    if (depth) {
        depth--;
```

This is the piece I had wrong twice. A further `<` does not start a new tag; it *increments a counter*. A `>` does not end the tag; it *decrements the counter*, and only closes when it arrives with the counter already at zero. Nothing in between is written to the output.

So the exit condition is a `>` with nothing owing — **an orphan `>`**. And well-formed HTML does not contain orphan closing brackets: every `>` in the rest of the document belongs to a tag whose `<` has already incremented the counter past it. The exit never arrives. When the buffer runs out, the `finish:` label returns whatever had been emitted and the function is done. There is no recovery step, no flush of the pending text, and no diagnostic of any kind.

You can see the two models disagree in nine characters:

```
strip_tags("A<b<c>d>E")   →   "AE"
```

If each `<` paired with the next `>`, the result would be `Ad>E`. It is not. The counter goes up twice and down twice, and the whole span disappears.

## Two explanations I got wrong before this one

I published both of them internally on the day, hours apart, and the second one was in a comment in production code for about an hour. They are worth setting out because the reasoning that produced them is more common than the bug.

**First attempt: “it tracks quote state.”** The post contains HTTP `Link` headers, which look like `Link: <https://example.com/a.md>; rel="alternate"`. I decided the unbalanced quote inside a fake tag was swallowing the rest. It explained the size, it sounded like something a C scanner would do, and it was **false**. Ten seconds of isolation would have killed it, and eventually did: text after a stray quote survives intact. What makes this one instructive is that `strip_tags` *does* track quotes — the flag is right there on line 4803 and toggles at 4918 — so the explanation was not even wrong about the machinery. It was wrong about which part of the machinery was load-bearing, which is a much easier mistake to make and a much harder one to notice.

**Second attempt: “each `<` pairs with the next `>`.”** Better, and still wrong, and wrong in the direction that *understates* the damage: that model predicts a local loss followed by resynchronization, alternating live and dead fragments. The reality is a single cut with nothing after it. I only found out because I put the claim in front of adversarial review before publishing it, and the test that killed it is the nine-character string above.

**And a third thing I asserted that I have since withdrawn:** that counting `<` against `>` is the diagnosis. It found this one, but it is not reliable in either direction. An orphan `>` in ordinary text — a shell redirect, a `->`, a `>=` — arrives at depth zero and *resynchronizes* a document that was already broken. The count can balance while the document is destroyed, and it can be unbalanced while nothing is wrong. It is a hint, not a test.

## The one thing that could save you, and the apostrophe that stops it

There is a way out of the swallowed state, and it is the orphan `>`: a closing bracket in ordinary text, arriving with the counter at zero. Shell examples produce them constantly — `2>/dev/null`, `->`, `>=` — so a broken document can repair itself further down and lose only the span in between:

```
strip_tags("uno <- dos > tres CUATRO CINCO")
  → "uno  tres CUATRO CINCO"          the loss stops at the orphan >
```

Now put a single apostrophe in the swallowed span, of the kind English prose is made of:

```
strip_tags("uno <- dos don't tres > CUATRO CINCO")
  → "uno "                            everything gone
```

Inside tag state, a quote toggles `in_q`, and while that flag is set a `>` does not count. **One apostrophe converts a recoverable local loss into total loss.** Add a second and it recovers again, because the flag toggles back:

```
strip_tags("uno <- dos don't it's tres > CUATRO CINCO")
  → "uno  CUATRO CINCO"               two quotes, balanced, recovered
```

So whether you lose a paragraph or the article depends on the *parity of the apostrophes* between the stray bracket and the next orphan `>`. That is not a rule anyone can hold in their head while writing, and it is the reason the damage in a real English article is almost always total rather than partial.

It is also the piece that made my first wrong explanation so convincing. Quotes really are involved. They just cannot start the fire — the branch that reads them does not exist until the scanner is already inside a tag.

**What this looks like as a service.** The bug was not hard. What took the time was noticing that a number was wrong at all, and then refusing to keep the first explanation that fit the number.

That is most of what the [free check](https://visibleorigin.com/services/#free-check) is: you send a domain, I request it as five named crawlers, one at a time, and send back exactly what each one received along with the command that asked. Everything in it is a command you can run yourself, which is the only reason it is worth anything.

## Where this reaches inside WordPress

The function is not obscure and neither are its callers. WordPress core wraps it in `wp_strip_all_tags()`, which removes `script` and `style` with a regular expression and then hands the rest straight to `strip_tags()`. It inherits the behavior completely, and so does `wp_trim_words()`, which is what produces an automatic excerpt. In the copy running this site — WordPress 7.1 — those live in [wp-includes/formatting.php](https://github.com/WordPress/wordpress-develop/blob/trunk/src/wp-includes/formatting.php) at lines 5600, 4120 and 4029 respectively.

The path that matters for a post is `wp_trim_excerpt()`, and it has two properties that decide whether you ever see this:

- **If the post has a manual excerpt, that function returns it and never calls `strip_tags` at all.**
- **The automatic excerpt is cut to 55 words.** If the stray character sits after word 55, the excerpt is byte-identical to the correct one.

On this site the stray character was at roughly word 1,100 of 3,700, and every post here carries a manual excerpt. So I checked the surfaces before claiming anything, and they are clean: the RSS feed serves full descriptions, and the card on the index page shows the intended text. **There was no visible damage anywhere, which is exactly why it survived.** The only thing that ever noticed was a word count I had just added and bothered to verify.

## The patterns that trigger it

The rule is narrow and easy to hold: **a `<` followed by any character that is not a space, a tab or a newline.** In technical writing that is not a rare accident, it is a house style.

| Pattern | What happens |
| --- | --- |
| `<-` as an annotation arrow | **everything after it, to the end** |
| `a<b`, `i<3`, `x<=y` written without spaces | **everything after it, to the end** |
| `<<EOF` and other heredocs | **everything after it, to the end** |
| `Vec<T>`, `List<String>` | the bracketed span disappears, quietly |
| `a < b` with spaces | nothing — safe |
| `->`, `2>/dev/null`, `>=` | nothing — and can even repair a broken document |

My own posts had five of these across two articles: one in [a piece that had been published for two days](https://visibleorigin.com/serving-markdown-to-agents/), and four in [one that was scheduled to go out the following morning](https://visibleorigin.com/cloudflare-no-store-cache-rule/). Both are now written as `&lt;`, which changes nothing on the page: the reader still sees the arrow.

That is a correction rather than a workaround, and the specification is unambiguous about which side of it I was on. [The HTML standard](https://html.spec.whatwg.org/multipage/syntax.html#syntax-text) says: “Normal elements can have text, character references, other elements, and comments, but the text must not contain the character U+003C LESS-THAN SIGN (<) or an ambiguous ampersand.” My documents contained it. They were invalid, browsers rendered them anyway, and nothing in the chain between my keyboard and the page ever said so.

## Why the obvious replacement is also broken

The natural fix is to strip only things that look like real tags, with a regular expression instead of the scanner. Most versions of that idea have the same failure, and the reason is worth a minute because it is the same primitive wearing different clothes.

```
</?[a-zA-Z][a-zA-Z0-9]*[^>]*>          ← the common form. Still broken.
```

The trap is `[^>]*`: it will cross anything at all to reach the next `>`. Feed it a document containing `if (a<b) return;` inside a long code block and `<b` matches a real element name, `[^>]*` runs to the closing `</code>`, and the whole block is swallowed. Measured on a 24 KB test document: 3,003 words of reference, `strip_tags` returns 2, this regular expression returns 2. Identical.

What fixes it is one character in the pattern:

```
</?[a-zA-Z][a-zA-Z0-9-]*(\s[^<>]*)?/?>   ← note [^<>], not [^>]
```

Excluding `<` from the attribute run means a match can never cross into another opening bracket, so it cannot swallow a code block; and requiring whitespace after the tag name means `<b)` does not match anything at all. On the same test document it returns 3,203 words against a reference of 3,203. I am not claiming it is a correct HTML parser — it is not, and it will happily leave the contents of an HTML comment in the text. I am claiming it is safe for the narrow job of counting words, which is the job I had.

**Where this stops being a blog post.** A function in every PHP install, called by the CMS that runs a third of the web, deletes most of your text on input that a technical writer produces without thinking, and reports nothing. Whether it has ever bitten you depends on details as arbitrary as whether your excerpts are written by hand.

Finding this class of gap on infrastructure somebody else built, and saying which layer produced it, is [the work I do for money](https://visibleorigin.com/services/). It arrives the way this note does: the command, its output, and a clearly marked line between what I measured and what I only believe.

## What the manual says, and what it does not

The [documented description](https://www.php.net/manual/en/function.strip-tags.php) chooses its verb carefully: the function “*tries* to return a string with all NULL bytes, HTML and PHP tags stripped from a given string.” There is one warning that touches this, and it is accurate as far as it goes:

> Because strip_tags() does not actually validate the HTML, partial or broken tags can result in the removal of more text/data than expected.

What that sentence does not say is how much more. Not that the loss runs to the end of the document rather than to the end of the tag; not that the trigger is a single unescaped `<`; and not that whitespace after the bracket makes it harmless. The word “expected” is doing an enormous amount of work: my expectation was a few characters and the answer was three quarters of the article.

The community notes on that same page are more use than the documentation. One posted twelve days before I hit this reports the identical behavior and calls it a bug, with an example any of us could have written:

```
$STR = "field <= '2026-08-25'";   // everything after <= is removed
```

And a note sixteen years older already gives the mitigation: put a space after the bracket. Two people found this a decade and a half apart, in the same place, and it is still not in the description of the function.

## How to check your own

If you generate excerpts, meta descriptions, search indexes, word counts or plain-text emails from HTML that contains code samples, this is two lines:

```
php -r '$c = file_get_contents("post.html");
        printf("%d in, %d out\n", strlen($c), strlen(strip_tags($c)));'
```

A drop of fifteen to twenty per cent is the tags themselves and is normal. A drop of seventy per cent is this. To find the character, look for an opening bracket that is not followed by a letter, a slash, an exclamation mark or whitespace — and fix it by escaping it as `&lt;`, not by changing the function, because the invalid HTML is the actual defect.

## strip_tags in PHP: frequently asked questions

### Why does strip_tags delete my whole string?

Because the string contains a `<` that is not followed by whitespace and is not the start of a real tag. That puts the scanner into tag state, and the only thing that gets it out is a `>` arriving with its internal depth counter at zero. In well-formed HTML that never happens, so everything from that character to the end of the input is discarded.

### Why is “a < b” safe but “a <- b” is not?

Because of one branch in the implementation: a `<` immediately followed by a space, tab or newline is copied to the output as an ordinary character. That branch is active whenever you call `strip_tags()`, and it is the only thing standing between ordinary prose about inequalities and total loss.

### Does wp_strip_all_tags in WordPress have the same problem?

Yes. It removes `script` and `style` blocks and then calls `strip_tags()`, so it inherits the behavior unchanged, and `wp_trim_words()` does too. Whether you notice depends on whether your posts have manual excerpts and on where in the text the character falls, since automatic excerpts are cut at 55 words.

### Is this a PHP bug?

It is documented behavior, described in one sentence that understates it considerably. The function has never claimed to parse HTML, and invalid input is invalid input. What is fair to call a defect is the silence: a scanner that discards three quarters of its input and returns success is not giving its caller a chance.

### What should I use instead?

For displaying untrusted HTML, none of this — use a real sanitizer. For reducing your own HTML to text, either fix the input so it is valid, or use a pattern that cannot run across an opening bracket. If you need genuine correctness rather than a word count, parse it with something that parses.

### How do I find out whether this is happening to me?

Compare the length before and after on your longest article with the most code in it. The failure has no other symptom: no exception, no log line, no truncation marker, and often no visible change on any page, because the surfaces that would show it are frequently fed from somewhere else.

## The general shape of this

Nothing here malfunctioned. PHP did what its source says. WordPress called it the way it has for years. My content was invalid HTML and no layer is obliged to notice. The word count was wrong by a factor of three and every system in the chain reported success, because none of them was asked the question I cared about.

The part I keep returning to is that **the failure was only ever visible in a number I had just decided to verify.** Had I trusted the first output, a wrong figure would have gone into the structured data of every post, and the thing that would eventually have surfaced it is not a bug report but somebody quoting my own metadata back at me.

And the two wrong explanations are the more useful lesson. Both were plausible, both were about real mechanisms in the real source, and both were arrived at by reasoning from a number instead of by testing in isolation. **The distance between an explanation that fits the evidence and one that has survived an attempt to break it is the entire distance between being right and being about to be embarrassed.**

Removing something is not always removing it, either: [I deleted an analytics plugin, and its settings stayed behind and switched the tracker back on](https://visibleorigin.com/deleted-plugin-settings-stayed/).

Every command here ran against this server on the day this was published, and every line number is from the source tag matching the interpreter that ran them. If one of them returns something other than what I said it does, that is worth an email. And if you would rather have somebody else check whether your own stack is quietly subtracting things, that is what the [free check](https://visibleorigin.com/services/#free-check) is for.
