Acento quebrado no PHP: confira o UTF-8 camada por camada, do arquivo ao banco
Descubra pelo sintoma qual camada está trocando o encoding (arquivo, Apache, PHP, HTML ou MySQL) e deixe todas em UTF-8 sem converter texto na sorte.
A recepcionista da clínica cadastra a paciente "Maria da Conceição" e a agenda do dia mostra "Maria da Conceição". No phpMyAdmin o nome aparece certo. No relatório em PDF aparece um losango com interrogação. Três telas, três versões do mesmo nome, e cada desenvolvedor que passou pelo sistema acrescentou um utf8_encode num lugar diferente para "consertar".
A resposta curta: texto quebrado é sempre uma camada lendo bytes com um encoding diferente do que foi usado para gravar. O conserto não é converter mais uma vez. É pôr UTF-8 em todas as camadas: o arquivo PHP, o cabeçalho que o Apache ou o PHP envia, a tag meta, a conexão com o MySQL e a coluna. E o sintoma na tela já diz qual camada está fora.
Por que o mesmo texto aparece de três jeitos
O computador não guarda "ç". Guarda bytes. Em UTF-8, o "ç" vira dois bytes, C3 A7. Em Latin-1 (ISO-8859-1) ou no Windows-1252, vira um byte só, E7. Quem lê precisa saber qual tabela foi usada. Se o arquivo foi gravado numa e lido na outra, aparece lixo, e cada tipo de lixo tem uma causa:
- "ç", "ã", "é": bytes UTF-8 lidos como Latin-1. Os dois bytes do "ç" viraram dois caracteres. Normalmente a página foi anunciada como ISO-8859-1, ou o texto foi convertido para UTF-8 uma segunda vez antes de gravar.
- Losango com interrogação: o contrário. Um byte Latin-1 chegou onde se esperava UTF-8, formou uma sequência inválida e o navegador pôs o caractere de substituição. Arquivo salvo como "ANSI" no editor é o suspeito número um.
- Interrogação comum no lugar da letra: o dado foi convertido para um encoding que não tem aquele caractere, e a conversão desistiu. Acontece ao gravar emoji numa coluna
latin1ou na antigautf8de três bytes do MySQL. Aqui não há conserto: o caractere original foi perdido na gravação. - "" no topo ou "headers already sent": o arquivo PHP foi salvo com BOM, três bytes invisíveis no começo que o PHP envia antes do seu
header().
As camadas, na ordem em que o texto passa
No sistema da clínica, o nome digitado percorre este caminho: formulário HTML, requisição, PHP, conexão, tabela, e o caminho de volta até a tela. Cada camada tem um lugar onde o encoding é declarado.
- Arquivos do projeto em UTF-8 sem BOM. No VS Code, o encoding aparece na barra de status, embaixo à direita, e você pode clicar para "salvar com encoding". No Linux,
file -i agenda.phpmostra o charset detectado. - Cabeçalho HTTP. É ele que manda no navegador. No
php.ini,default_charset = "UTF-8"faz o PHP enviarcharset=UTF-8noContent-Type, e esse já é o padrão nas versões atuais. Para arquivos estáticos servidos pelo Apache,AddDefaultCharset UTF-8no virtual host cobre HTML e texto puro que saírem sem charset. - Tag
meta:<meta charset="utf-8">como primeiro elemento dentro dohead. Ela vale quando a página é aberta do disco ou salva, e o navegador precisa encontrá-la logo no começo do documento. - Conexão com o MySQL. A conexão tem encoding próprio, independente da coluna. Se ela estiver em
latin1e a coluna em UTF-8, o MySQL converte no caminho e é aí que nasce a dupla codificação. - Coluna e tabela em
utf8mb4. No MySQL,utf8é um apelido antigo parautf8mb3, que não guarda emoji nem alguns caracteres raros. Useutf8mb4. - Funções de string.
strlenconta bytes, não letras. Para cortar ou contar texto, usemb_strlenemb_substr, como explica strlen não conta letra.
Na prática, o PHP da clínica ficou assim:
// config.php
ini_set('default_charset', 'UTF-8');
mb_internal_encoding('UTF-8');
$pdo = new PDO(
'mysql:host=localhost;dbname=clinica;charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// Para quem usa mysqli:
// $db = new mysqli('localhost', getenv('DB_USER'), getenv('DB_PASS'), 'clinica');
// $db->set_charset('utf8mb4');
E a tabela:
ALTER TABLE pacientes
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Rode o ALTER só depois de ter backup e de saber que os dados atuais estão corretos dentro do banco. CONVERT TO traduz os bytes de um encoding para outro. Se o texto já estava duplamente codificado, ele continua duplamente codificado, só que em utf8mb4.
Como descobrir qual camada está mentindo
Não confie no que a tela mostra, porque a tela passa por todas as camadas. Olhe os bytes em cada ponta.
Primeiro, o cabeçalho que o servidor manda:
curl -sI https://clinica.example/agenda.php | grep -i content-type
# Content-Type: text/html; charset=UTF-8
Depois, o que está realmente gravado no banco. HEX mostra os bytes, sem conversão de tela no meio:
SELECT nome, HEX(nome) FROM pacientes WHERE id = 1042;
SHOW VARIABLES LIKE 'character_set%';
SHOW CREATE TABLE pacientes;
Para o "ç" da Conceição, C3A7 significa UTF-8 correto. E7 significa Latin-1. C383C2A7 é o caso clássico de dupla codificação: os bytes do UTF-8 foram tratados como Latin-1 e codificados de novo. Na clínica, era isso. A conexão estava em latin1, a coluna em UTF-8 e as páginas em UTF-8. Tudo parecia certo no phpMyAdmin porque ele errava do mesmo jeito na leitura e o erro se anulava.
Corrigir dado já corrompido é uma operação à parte, feita uma vez, com backup e em cópia antes. Não se faz isso na exibição. O passo a passo está em acento quebrado não se conserta convertendo de novo, e o lado específico do MySQL em acentos que quebram só quando vêm do banco.
Os três lugares que costumam escapar
JSON: json_encode exige UTF-8 válido. Com um byte Latin-1 no meio, ele devolve false e a API responde vazio. Confira com json_last_error_msg() antes de culpar o JavaScript.
CSV para Excel: aqui o BOM ajuda. O Excel abre melhor um CSV em UTF-8 se ele começar com "\xEF\xBB\xBF". É o único lugar desta lista em que você quer esses três bytes.
E-mail e PDF: bibliotecas de PDF e de e-mail têm configuração própria de charset e de fonte. Se o PDF mostra losango e a tela não, o problema está na fonte ou no charset da biblioteca, não no banco.
Erros comuns
- Espalhar
utf8_encodeeutf8_decodepelo código. Cada chamada conserta uma tela e quebra outra, e as duas funções estão obsoletas desde o PHP 8.2. - Configurar a coluna e esquecer a conexão. O MySQL converte no caminho e grava dupla codificação que só aparece fora do phpMyAdmin.
- Usar
utf8no MySQL achando que é UTF-8 completo. Emoji e alguns caracteres viram interrogação e se perdem para sempre. - Salvar arquivo PHP com BOM. Aparece lixo no topo da página e o
header()para de funcionar. - Rodar
CONVERT TOem dado já corrompido. O lixo muda de tabela, não de forma. - Confiar só na tag
meta. O cabeçalho HTTP tem prioridade; se ele disser ISO-8859-1, ametaperde.
Checklist
- Todos os arquivos do projeto estão em UTF-8 sem BOM.
curl -sImostracharset=UTF-8noContent-Type.<meta charset="utf-8">é o primeiro item dohead.- A conexão declara
charset=utf8mb4no DSN ou emset_charset. SHOW CREATE TABLEmostrautf8mb4nas colunas de texto.HEX(nome)de um registro com "ç" devolveC3A7, nãoC383C2A7.- Não sobrou nenhum
utf8_encodeouutf8_decodeno código.
Cadastre agora uma paciente de teste chamada "Conceição", com um emoji no fim do nome, e rode SELECT HEX(nome) nele: os bytes que voltam são os de um UTF-8 limpo ou os de um texto codificado duas vezes?
Referência: http://www.nerdblog.info/2010/03/30/como-mudar-o-charset-para-aplicacoes-web-com-apache-e-php/