In PHP, how do you safely echo a user's review into an HTML page with htmlspecialchars(), and which flags and charset should you pass?
answer
- five characters become entities
- escape at output, every time
- ENT_QUOTES | ENT_SUBSTITUTE default since 8.1
- invalid UTF-8 without SUBSTITUTE: empty string
- charset from default_charset, UTF-8
basics
~20 sWrap every untrusted value in htmlspecialchars() at the moment it is echoed. Pass ENT_QUOTES | ENT_SUBSTITUTE and 'UTF-8', the PHP 8.1+ defaults, so both quote types are encoded and malformed UTF-8 cannot turn the output into an empty string.
solid answer
~40 s`htmlspecialchars($review, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')` converts `&`, `<`, `>`, `"` and `'` into entities, so the review renders as text instead of being parsed as markup. Since PHP 8.1 the default flags are `ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401`; before that the default was `ENT_COMPAT`, which left single quotes alone. `ENT_SUBSTITUTE` matters because an invalid byte sequence otherwise makes the function return an **empty string**, silently blanking the review. The encoding parameter defaults to `default_charset`, which is `UTF-8`, and must match the page's charset. Do it at output time, for every value, including ones you validated or stored yourself; and leave `double_encode` at `true` so text that merely looks like an entity is still shown literally. Most codebases wrap this in a short helper so no template writes the call by hand.
code
php · 14 lines<?php
declare(strict_types=1);
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$review = $row['body']; // "Great view <script>steal()</script> & 'quiet' rooms"
?>
<article class="review">
<h3><?= e($row['title']) ?></h3>
<p><?= e($review) ?></p>
</article>go deeper
Recall that every untrusted value echoed into HTML goes through htmlspecialchars() with ENT_QUOTES and UTF-8, and which five characters it converts.
Explain ENT_SUBSTITUTE and the empty-string failure, the 8.1 default change, why explicit flags replace defaults, the charset link to default_charset, and double_encode.
Show how you enforce escape-at-output through one helper or an auto-escaping layer, audit old helpers missing ENT_SUBSTITUTE, and find double-escaped data.
Argue for making escaping the default in the rendering layer so safety does not depend on every template author remembering a function call.
## What the function does `htmlspecialchars(string $string, int $flags = ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, ?string $encoding = null, bool $double_encode = true): string` replaces the characters that have special meaning in HTML with entities: | Character | Replacement | |---|---| | `&` | `&` | | `<` | `<` | | `>` | `>` | | `"` | `"` (unless `ENT_NOQUOTES`) | | `'` | `'` with `ENT_HTML401`, `'` with `ENT_HTML5`, `ENT_XHTML` or `ENT_XML1`, only when `ENT_QUOTES` is set | After conversion the browser displays the characters instead of interpreting them, so a review containing `<script>` shows the text `<script>` on a travel site's hotel page. ## The flags - **`ENT_QUOTES`** encodes both double and single quotes. That makes one escaped value safe inside HTML text and inside either kind of quoted attribute. - **`ENT_SUBSTITUTE`** replaces invalid code unit sequences with U+FFFD, the replacement character. Without it (and without `ENT_IGNORE`), an invalid sequence makes the function return an **empty string**: a review pasted from a mis-encoded source simply disappears. - **`ENT_HTML401`** or **`ENT_HTML5`** chooses the document type; it mainly changes how the single quote is written. Either is fine for HTML pages. - **`ENT_IGNORE`** silently drops invalid sequences. Avoid it: removing bytes can join characters into something unexpected. ## The PHP 8.1 default change PHP 8.1 changed the default flags of `htmlspecialchars()`, `htmlentities()` and their decode counterparts from `ENT_COMPAT` to `ENT_QUOTES | ENT_SUBSTITUTE` (with `ENT_HTML401`). Two consequences: 1. Code calling `htmlspecialchars($s)` with no flags is now safe in single-quoted attributes, which it was not before 8.1. 2. Code that passes flags explicitly, often an old helper with `ENT_QUOTES` alone, **does not** get `ENT_SUBSTITUTE`, because explicit flags replace the default rather than adding to it. Such helpers still blank out malformed input on PHP 8.5. Writing the flags explicitly, `ENT_QUOTES | ENT_SUBSTITUTE`, documents the intent and behaves the same on every version. ## The charset The third argument names the input encoding. When it is `null`, PHP uses `default_charset`, whose built-in and shipped value is `UTF-8`. The value must match how the page is actually served, typically through the `Content-Type` header's charset. A mismatch is how multibyte tricks slip past escaping in legacy stacks, so pass `'UTF-8'` explicitly and serve UTF-8 pages. ## double_encode With the default `double_encode = true`, an existing `&` in the input becomes `&amp;`, so the reader sees exactly what the reviewer typed. Setting it to `false` leaves existing entities alone. That is only correct for input you know is already partly encoded, and it lets a user smuggle entities through, so keep the default for user text. ## Where and when to call it - **At output**, in the template, for every value that did not originate as trusted markup. Not at input: the same review also goes to emails, CSV exports and JSON APIs, where HTML entities would be corruption. - **For every source**: request data, database rows, API responses, file names. "It was validated" does not mean "it is HTML-safe". - **Once**: escaping stored data again on output produces visible `&lt;` if something already escaped it on input. A two-line helper, commonly called `e()`, removes the temptation to skip it: - it fixes the flags and charset in one place; - it gives reviewers one pattern to look for; - it can accept `null` from optional fields by converting it to an empty string first. ## Mistakes found in older code - **Helpers with `ENT_QUOTES` only.** Safe for quotes, but malformed input still produces an empty string; add `ENT_SUBSTITUTE`. - **`ENT_COMPAT` passed explicitly.** Single quotes stay raw even on PHP 8.5; single-quoted attributes remain breakable. - **A different charset per call.** Mixing `'ISO-8859-1'` and `'UTF-8'` across templates produces mojibake or blanks; fix the charset in the helper. - **Escaping inside the data layer.** A repository method that returns pre-escaped strings forces every non-HTML consumer to decode, and every template author to remember not to escape again. - **`null` from optional columns.** Passing `null` to the `string` parameter is deprecated for internal functions since PHP 8.1; the helper should convert it explicitly. ## What it does not cover `htmlspecialchars()` is the encoder for HTML **text** and **quoted attribute values**. Other positions need other encoders: an `href` needs its URL built with `rawurlencode()` and a scheme check, data inside `<script>` needs `json_encode()` with `JSON_HEX_*` flags, and an unquoted attribute or an event-handler attribute cannot be made safe by this function at all.
- What does htmlspecialchars() return for a string with an invalid UTF-8 byte if you pass only ENT_QUOTES?An empty string. Explicit flags replace the default, so `ENT_SUBSTITUTE` is missing, and without it or `ENT_IGNORE` an invalid code unit sequence makes the whole result empty. Pass `ENT_QUOTES | ENT_SUBSTITUTE` so the bad byte becomes U+FFFD and the rest of the text survives.
- Why escape at output rather than once when the review is saved?The stored review feeds several outputs, HTML pages, emails, CSV exports and JSON APIs, and only one of them wants HTML entities. Escaping on input corrupts the others and invites double escaping on the page. Store the raw text and let each output apply its own encoder.
- When would you set double_encode to false?Only for input you know already contains intentional entities, such as a legacy column that was stored partly encoded. For user text keep the default `true`: with `false`, a reviewer can type `<` and have it render as `<`, and the page no longer shows exactly what was written.
saying these in an interview costs you the question
- htmlspecialchars() without flags leaves single quotes unescaped in PHP 8.5
- Passing ENT_QUOTES alone still substitutes invalid UTF-8
- Escaping user input once when it is saved is enough
- Values already validated do not need escaping on output
- htmlspecialchars() makes a value safe in any part of a page