← Voltar aos artigos

utf-8 · strings · charset · encoding · mbstring

Por que os acentos aparecem quebrados (�) no PHP e como resolver

Publicado em 21/07/2026 · 9 min de leitura

Se os acentos do seu PHP aparecem como ou é, não é bug do PHP: é um desencontro de charset entre os pontos por onde o texto passa — o arquivo onde você digitou, o cabeçalho HTTP que o navegador recebe, a conexão com o banco e as funções que manipulam a string. Quando todos falam UTF-8, o é continua é do começo ao fim. Basta um deles falar outra língua (quase sempre ISO-8859-1/latin1) para a letra virar lixo. A boa notícia: dá para descobrir exatamente qual ponto está errado em vez de mexer no escuro — é o que este artigo mostra, junto com o caminho UTF-8 completo e como recuperar dados que já quebraram.

O que realmente acontece (no nível dos bytes)

Um caractere como é não é "uma letra" para o computador — é uma sequência de bytes, e o charset é a tabela que diz quais bytes formam qual letra. Isso é a raiz de tudo, então vale ver de perto:

echo bin2hex("é");   // c3a9   → em UTF-8, "é" são DOIS bytes

Em UTF-8, é é o par de bytes C3 A9. Já em ISO-8859-1, o mesmo é é um byte só: E9. Agora imagine que o texto foi gravado em UTF-8 (bytes C3 A9) mas alguém esses bytes como ISO-8859-1: a tabela latin1 diz que C3 é à e A9 é ©, então aparece é. Ninguém alterou o byte — mudou a interpretação.

O é o outro sintoma, e tem causa oposta: o programa esperava UTF-8 mas recebeu um byte que, sozinho, não forma um caractere UTF-8 válido (como um E9 solto vindo de um arquivo latin1). Sem conseguir decodificar, ele exibe o "caractere de substituição" (U+FFFD).

A lição que resolve 90% dos casos: o byte nunca muda sozinho — o que muda é quem o interpreta. Por isso a correção nunca é "escapar o acento" nem "trocar o é por é". É fazer todos os pontos interpretarem os bytes do mesmo jeito.

Antes de corrigir: descubra QUAL ponto está errado

O erro clássico é sair mudando tudo e piorar (ou "consertar" o lugar certo e continuar quebrado porque o problema era outro). São quatro suspeitos; identifique o culpado olhando o sintoma:

E, para não depender do olho, inspecione os bytes de verdade na origem:

$txt = "José";
echo bin2hex($txt);   // 4a6f73c3a9 → termina em c3a9 = UTF-8 correto
                      // 4a6f7365e9 → termina em e9    = está em latin1

Se bin2hex já mostra o dado errado, o problema é na entrada (arquivo/banco). Se o dado está certo em bytes mas aparece quebrado na tela, o problema é na saída (header/meta). Esse teste sozinho evita horas no escuro.

Evite mb_detect_encoding() para isso: ele adivinha por heurística e erra com frequência entre UTF-8 e latin1. Serve para um chute em dado desconhecido, nunca como fonte da verdade.

O caminho UTF-8 fim-a-fim

Diagnosticado o ponto, o conserto é alinhar a cadeia inteira. São quatro elos:

1. O arquivo .php salvo em UTF-8. Se você digitou num editor configurado para ISO-8859-1 (ou "ANSI" no Windows), os bytes já nascem errados na origem e nada mais adianta — é a causa nº 1 do . No VS Code, o charset aparece no canto inferior direito; deve dizer "UTF-8". Prefira UTF-8 sem BOM: o BOM é uma marca invisível de 3 bytes no início do arquivo que o PHP trata como saída, disparando o famoso headers already sent e um espaço fantasma no topo da página.

2. A saída HTTP declarando o charset. O navegador precisa ser avisado de que a página é UTF-8, senão ele adivinha — e adivinha errado. Aqui vale uma nuance importante que poucos sabem: desde o PHP 5.6 o default_charset já é UTF-8, e o PHP anexa ; charset=UTF-8 sozinho ao Content-Type de páginas text/html. Ou seja, na maioria das instalações modernas a saída já está coberta por padrão — se o seu é persiste, o culpado provavelmente é o arquivo ou o banco, não o header. Ainda assim, seja explícito (blindagem contra um default_charset alterado) e sempre inclua a <meta>:

header('Content-Type: text/html; charset=UTF-8');
<meta charset="UTF-8">

3. A conexão com o banco em utf8mb4. Este é o elo mais esquecido. Não basta a coluna ser UTF-8: a conexão tem seu próprio charset, e se ela estiver em latin1 o MySQL converte seus bytes na ida e na volta, corrompendo o acento mesmo com tudo o mais certo. Declare no DSN:

$pdo = new PDO(
    'mysql:host=localhost;dbname=app;charset=utf8mb4',
    $usuario,
    $senha,
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

O charset=utf8mb4 no DSN é a forma correta e moderna. Você talvez veja tutoriais mandando rodar SET NAMES utf8 na mão — funciona, mas é inferior: é uma query extra, não atualiza a propriedade de charset da conexão para o driver, e usa o utf8 incompleto. Prefira o DSN.

4. As funções que manipulam a string. É o elo que fecha a conta — e o tema da próxima seção.

strlen conta bytes, mbstring conta letras

Com o texto viajando correto, aparece a segunda classe de bug: as funções "clássicas" de string do PHP operam byte a byte, não letra a letra. Elas nasceram numa época em que 1 byte = 1 caractere, o que só vale para ASCII puro. Em UTF-8 um acento ocupa mais de um byte, então:

A família mb_* (multibyte string) entende UTF-8 e trabalha por caractere. Sempre que a string puder ter acento, emoji ou qualquer coisa fora do ASCII, use a versão mb_:

evite
$nome = "José";
echo strlen($nome);      // 5 — conta BYTES (o "é" vale 2: c3 a9)
echo strtoupper($nome);  // JOSé — o acento fica de fora
echo substr($nome, 0, 3);// "Jos" seguido de meio "é" → pode virar �
prefira
$nome = "José";
echo mb_strlen($nome);      // 4 — conta LETRAS
echo mb_strtoupper($nome);  // JOSÉ — acento incluído
echo mb_substr($nome, 0, 3);// "Jos" — corta certo, por caractere

Toda a família tem par: mb_substr, mb_strpos, mb_str_split, mb_convert_case, mb_stripos. Defina o encoding padrão uma vez, logo no início da aplicação (num bootstrap), e pode omitir o 'UTF-8' em cada chamada:

mb_internal_encoding('UTF-8');

Uma armadilha vizinha que vale citar: ao escapar HTML, htmlspecialchars() também tem charset. Desde o PHP 5.4 o default já é UTF-8, mas se sua string for latin1 ele devolve '' (string vazia) em vez de erro visível. Passe o charset quando quiser garantia: htmlspecialchars($s, ENT_QUOTES, 'UTF-8').

As duas pegadinhas mais comuns

E o que já está gravado quebrado?

Corrigir a cadeia impede novos acentos quebrados, mas não conserta o que já está no banco ou num arquivo legado. Para converter dado que está em latin1 para UTF-8:

$ok = mb_convert_encoding($textoLatin1, 'UTF-8', 'ISO-8859-1');
// alternativa equivalente:
$ok = iconv('ISO-8859-1', 'UTF-8', $textoLatin1);

Caso especial: dupla codificação (o é que ficou gravado assim porque o texto foi convertido para UTF-8 duas vezes). O truque é "desfazer uma volta" — reinterpretar como latin1 e reconverter:

$corrigido = mb_convert_encoding($textoDuplo, 'ISO-8859-1', 'UTF-8');

Onde alinhar cada charset

Ponto O que fazer Sintoma se errar
Arquivo .php Salvar em UTF-8 sem BOM Acentos quebrados na origem (); BOM → headers already sent
Saída HTTP header(... charset=UTF-8) + <meta charset="UTF-8"> Navegador adivinha → é
Conexão do banco charset=utf8mb4 no DSN + coluna utf8mb4 Corrompe ao ler/gravar
Manipular strings Usar mb_* no lugar de strlen/substr/strtoupper Contagem errada, corte no meio da letra,
Dado legado mb_convert_encoding/iconv, uma vez, com backup Continua quebrado, ou dupla codificação

Alinhou os quatro elos e migrou o que já estava gravado? O e o é somem — e não voltam.

no manualMultibyte String Functions

As funções mb_* são o kit oficial do PHP para texto multibyte. Conhecer a família inteira (comprimento, corte, busca, caixa, conversão) é o que blinda seu código contra acento quebrado.

estudar na árvore →
no manualStrings

Entender que uma string em PHP é uma sequência de bytes — e não de caracteres — é a base que faz todo o resto (bin2hex, strlen vs mb_strlen, charset da conexão) fazer sentido.

estudar na árvore →

Leve o estudo além do artigo

O manual inteiro do PHP como uma jornada: progresso por tópico, anotações e leitura embutida.