Texto com entidade HTML aparecendo na tela: escape uma vez, só na saída
Descubra por que o nome do produto aparece com &, pare de gravar texto escapado no banco, escape no template conforme o contexto e limpe os dados antigos com segurança.
A padaria cadastrou o produto "Pão & Cia Integral". Na vitrine do site aparece "Pão & Cia Integral". A atendente edita o produto para consertar, salva sem mexer em nada, e agora a vitrine mostra "Pão & Cia Integral". A cada edição, o nome fica mais estranho. Não é o teclado, não é o navegador: o texto está sendo escapado duas vezes.
A resposta curta: guarde no banco o texto exatamente como a pessoa digitou e escape uma única vez, no momento de colocá-lo no HTML, com a função adequada ao contexto. Com UTF-8 de ponta a ponta, você não precisa de entidade para acento nem para símbolo. Entidade fica para os poucos caracteres que têm significado na marcação, e quem cuida delas é a função de escape do template.
Por que o & aparece e cresce a cada edição
Escapar é trocar caracteres que o HTML interpreta por sequências que ele mostra literalmente: o & vira &, o < vira <, as aspas viram ". O navegador lê & e desenha um &. Uma camada de escape, um caractere na tela. Perfeito.
O problema começa quando alguém, com boa intenção, escapa na entrada "para evitar XSS". Veja o caminho do nome da padaria:
- O formulário envia
Pão & Cia Integral. - O controlador aplica
htmlspecialcharsantes do INSERT. O banco guardaPão & Cia Integral. - O template, corretamente, escapa de novo na saída. O HTML recebe
Pão &amp; Cia Integral. - O navegador desfaz uma camada e mostra
Pão & Cia Integral.
Na edição, o campo do formulário recebe o valor do banco, já escapado. O navegador mostra & dentro do campo, a atendente salva, o controlador escapa de novo e o banco ganha mais uma camada. É por isso que o texto piora a cada salvamento.
Há um segundo prejuízo, menos visível: tudo que lê o banco sem passar pelo HTML recebe lixo. O aplicativo, o JSON da API, a planilha exportada, o e-mail em texto puro, a busca. Todos passam a mostrar entidades, e cada um ganha um "conserto" local.
Entidade não é remédio para acento quebrado
A outra confusão comum é usar entidade para mascarar problema de encoding. O título aparece com um losango no lugar do "ã", alguém troca "ã" por ã na mão e o losango some. O sintoma foi embora; a causa ficou. Arquivo, banco, conexão e página estão em encodings diferentes, e o próximo texto vindo de outra fonte quebra de novo.
A correção é UTF-8 em todas as camadas, como explicam os posts sobre acentos no HTML e encoding de ponta a ponta. Com isso resolvido, "ã", "€" e "→" vão direto no texto, como caracteres Unicode. Entidade nomeada para acento é resquício de quando as páginas não declaravam encoding.
Como escapar do jeito certo em PHP
- Remova todo escape da entrada. Valide formato e tamanho, use consultas parametrizadas, e grave o texto como veio.
- Crie uma função de escape para HTML e use-a em todo valor dinâmico no template.
- Escape conforme o contexto: HTML e atributos com a função de escape, JavaScript com
json_encode, parâmetro de URL comrawurlencode. - Corrija os dados já gravados, com backup, decodificando uma camada por vez.
- Teste com um texto que tenha
&, aspas e<, salvando e reabrindo várias vezes.
A função de escape e o template
<?php
function e(?string $texto): string
{
return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
?>
<h2><?= e($produto['nome']) ?></h2>
<input name="nome" value="<?= e($produto['nome']) ?>">
<script>
const produto = <?= json_encode(['id' => $produto['id'], 'nome' => $produto['nome']], JSON_HEX_TAG | JSON_HEX_AMP | JSON_UNESCAPED_UNICODE) ?>;
</script>
ENT_QUOTES escapa aspas simples e duplas, o que protege valores dentro de atributos. ENT_SUBSTITUTE troca sequências inválidas de UTF-8 por um caractere de substituição em vez de devolver texto vazio. Dentro de <script>, entidade HTML não serve: o JavaScript recebe o texto literal, então o certo é json_encode, com as flags que impedem um </script> no meio do dado de fechar o bloco.
Se você usa um motor de templates como Twig ou Blade, o escape automático na saída já faz esse papel. Nesse caso, o escape manual no controlador é exatamente a segunda camada que causa o problema.
Limpando os nomes já gravados
Faça backup da tabela antes. Depois, decodifique uma camada nos registros que contêm entidades. Rode, confira uma amostra e repita até a consulta não trazer mais linhas, porque produtos editados muitas vezes têm várias camadas.
<?php
$pdo = new PDO(getenv('DB_DSN'), getenv('DB_USER'), getenv('DB_PASS'));
$linhas = $pdo->query(
"SELECT id, nome FROM produtos
WHERE nome LIKE '%&%' OR nome LIKE '%"%' OR nome LIKE '%'%'
OR nome LIKE '%<%' OR nome LIKE '%>%'"
)->fetchAll(PDO::FETCH_ASSOC);
$atualiza = $pdo->prepare('UPDATE produtos SET nome = ? WHERE id = ?');
foreach ($linhas as $linha) {
$limpo = html_entity_decode($linha['nome'], ENT_QUOTES | ENT_HTML5, 'UTF-8');
$atualiza->execute([$limpo, $linha['id']]);
echo $linha['id'] . ': ' . $linha['nome'] . ' => ' . $limpo . PHP_EOL;
}
O script imprime o antes e o depois de cada registro, para você revisar. Só rode depois de remover o escape da entrada; senão a próxima edição recria o problema. Se algum produto legitimamente contém a sequência & no nome, ele aparece na lista e você trata à mão.
Erros comuns
- Escapar na entrada e na saída: o texto ganha uma camada de entidade a cada salvamento e aparece corrompido em todos os canais.
- Usar
double_encode = falsecomo solução: esconde o sintoma, mantém o banco sujo e ainda impede que alguém escreva&de propósito. - Trocar acento por entidade para "consertar" encoding: o próximo texto de outra origem quebra igual.
- Entidade HTML dentro de JavaScript ou URL: cada contexto tem a sua regra, e o escape errado abre brecha ou quebra o dado.
- Esquecer o escape em um único template: basta um campo sem escape para virar XSS.
- Limpar o banco sem backup e sem revisar: uma decodificação a mais altera textos que estavam certos.
Checklist
- Nenhum controlador aplica
htmlspecialcharsantes de gravar. - Todo valor dinâmico no HTML passa pela função de escape ou pelo escape automático do template.
- Dados para JavaScript saem com
json_encode, não com entidade. - Banco, conexão, arquivos e página estão em UTF-8.
- A consulta por entidades na tabela de produtos retorna zero linhas.
- Um produto com
&, aspas e<no nome sobrevive a três edições seguidas sem mudar. - A API devolve o nome sem entidades.
Abra o cadastro de qualquer coisa no seu sistema, grave um nome com &, salve três vezes sem mexer e recarregue a vitrine: continua com um só & na tela?
Referência: https://unicode-table.com/pt/html-entities/