Acentos que aparecem certo na tela e quebram só quando vêm do MySQL
Descubra pelo HEX se o problema é a conexão ou o dado gravado, acerte utf8mb4 na tabela e na conexão e corrija as linhas antigas sem estragar as boas.
Você digita "ação" no formulário, salva, e a lista mostra "ação". O HTML declara UTF-8, o arquivo PHP está em UTF-8, o texto fixo da página sai perfeito. O estrago aparece só no que veio da tabela. A resposta curta: coluna, tabela e conexão precisam estar em utf8mb4, e a conexão se acerta no momento de conectar, com charset=utf8mb4 no DSN do PDO ou set_charset('utf8mb4') no mysqli. Antes de mexer em qualquer coisa, olhe o HEX do dado gravado para saber se o problema está só na leitura ou se o texto já foi gravado torto.
Por que "ç" vira "ç"
Texto no banco é uma sequência de bytes, e a codificação é a regra que diz qual letra cada byte representa. Em UTF-8, o "ç" ocupa dois bytes: C3 A7. Em latin1, cada byte é uma letra: C3 é "Ã" e A7 é "§". Quando bytes UTF-8 são lidos como se fossem latin1, cada letra acentuada vira duas letras estranhas. Esse é o "ç", e o padrão tem nome: mojibake.
No MySQL, a codificação aparece em três lugares que precisam concordar:
- A coluna, que define como o texto é guardado. Tabela e banco têm um padrão que vale para colunas novas.
- A conexão, que diz ao servidor em que codificação o cliente manda e quer receber o texto. O MySQL converte entre a conexão e a coluna.
- A página, que diz ao navegador como interpretar os bytes do HTML.
O caso mais traiçoeiro é a dupla codificação. A aplicação manda bytes UTF-8, mas a conexão diz "isto é latin1". O MySQL converte de latin1 para a codificação da coluna, e cada byte vira uma letra. O texto fica gravado errado, mas a aplicação, lendo pela mesma conexão errada, faz a conversão inversa e mostra certo. Tudo parece bem até alguém abrir o banco por outra ferramenta, ou corrigir a conexão, e as letras quebrarem de uma vez.
Utf8 não é utf8mb4
No MySQL, a codificação chamada utf8 é um apelido histórico para utf8mb3, que guarda no máximo três bytes por caractere. Acento funciona, emoji e alguns ideogramas não. Tentar gravar um emoji numa coluna utf8 dá erro ou corta o texto, dependendo do modo do servidor. Use sempre utf8mb4, que é o UTF-8 completo e o padrão do MySQL 8.
A collation é a regra de comparação e ordenação. No MySQL 8, o padrão utf8mb4_0900_ai_ci ignora acento e caixa nas comparações, então "joao" encontra "João". Se você usa MariaDB, que tem collations diferentes, utf8mb4_unicode_ci é uma escolha comum. O importante é usar a mesma em todas as tabelas, porque comparar colunas com collations diferentes gera erro de mistura em JOIN.
Como diagnosticar antes de mexer
- Veja a definição da tabela com
SHOW CREATE TABLE clientes;e anote o charset das colunas de texto. - Veja o que a sua conexão está usando, rodando pela aplicação
SHOW VARIABLES LIKE 'character_set_%';. - Grave uma palavra conhecida, como "ação", e olhe os bytes dela.
SELECT nome, HEX(nome) FROM clientes WHERE id = 42;
Para "ação" gravado corretamente em utf8mb4, o resultado é 61C3A7C3A36F: o "a", os dois bytes do "ç", os dois do "ã" e o "o". Se aparecer 61C383C2A7C383C2A36F, o texto foi duplamente codificado: cada byte virou um caractere de dois bytes. No primeiro caso o dado está bom e só a leitura está errada. No segundo, o dado precisa de correção.
Como acertar a conexão e a página
Com PDO, a codificação vai no DSN. Não dependa de um SET NAMES espalhado em cada página:
<?php
$pdo = new PDO(
'mysql:host=10.0.0.5;dbname=loja;charset=utf8mb4',
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
Com mysqli, use $mysqli->set_charset('utf8mb4'); logo depois de conectar. Esse método também informa a codificação às funções de escape, o que um SET NAMES enviado como consulta não faz.
Na página, mande o cabeçalho Content-Type: text/html; charset=utf-8 e mantenha <meta charset="utf-8"> no começo do head. Os arquivos do projeto devem estar salvos em UTF-8 sem BOM. O BOM no começo de um arquivo PHP sai antes de tudo e causa o famoso "headers already sent".
Como converter tabelas e corrigir dados antigos
Faça um backup que você já restaurou antes de qualquer passo abaixo.
Se a coluna está em latin1 e os dados estão realmente em latin1 (o HEX do "ç" é E7), a conversão é direta. O MySQL lê cada caractere e regrava em UTF-8:
ALTER TABLE clientes CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
Em tabela grande, esse comando reescreve tudo e pode travar escrita por bastante tempo. Rode em janela de manutenção ou com ferramenta de alteração online. Em versões antigas do MySQL, índice em VARCHAR(255) pode passar do limite de tamanho de chave com utf8mb4, porque cada caractere passa a reservar quatro bytes.
Se o dado está duplamente codificado, o CONVERT TO não resolve, porque ele converte o texto que já está errado. A correção desfaz a conversão indevida: transforma o texto de volta nos bytes latin1, trata esses bytes como binário e reinterpreta como UTF-8. Veja o resultado antes de gravar:
SELECT id, nome,
CONVERT(CAST(CONVERT(nome USING latin1) AS BINARY) USING utf8mb4) AS corrigido
FROM clientes
WHERE id IN (42, 57, 103);
Se a coluna corrigido mostra os nomes certos, aplique o mesmo cálculo num UPDATE restrito às linhas afetadas. Nunca rode isso na tabela inteira sem filtro: numa linha que já estava certa, a mesma conta estraga o texto. Se você tem duas gerações de dados misturadas, separe as linhas pelo HEX ou pela data em que a conexão foi corrigida.
Erros comuns
- Corrigir na saída com
utf8_encodeouutf8_decode. Mascara o problema, quebra o texto que já estava certo, e essas funções estão obsoletas desde o PHP 8.2. - Usar
utf8achando que é UTF-8 completo. O primeiro emoji num comentário gera erro ou texto cortado. - Arrumar a conexão sem olhar os dados antigos. Se eles estavam duplamente codificados, quebram na tela no minuto em que a conexão fica certa.
- Rodar a correção de dupla codificação na tabela inteira. As linhas boas viram lixo.
- Usar
strlenesubstrem texto com acento. Contam bytes, não letras, e cortam um caractere ao meio, que aparece como "�". Usemb_strlenemb_substr. - Collations diferentes entre tabelas. Um
JOINpor nome ou e-mail passa a dar erro de mistura de collation.
Checklist
- Todas as colunas de texto estão em
utf8mb4, com a mesma collation. - A conexão define
utf8mb4no DSN ou comset_charset. - A página envia
charset=utf-8no cabeçalho e na meta tag. - Os arquivos do projeto estão em UTF-8 sem BOM.
- O HEX de uma palavra acentuada recém-gravada bate com o UTF-8 esperado.
- Um emoji gravado pela aplicação volta igual.
- Nenhuma função de "conserto" de acento roda na exibição.
Grave agora "ação" pela aplicação e rode SELECT HEX(...) nessa linha. Saiu 61C3A7C3A36F? E quantas linhas antigas da mesma tabela ainda têm C383 no meio do HEX?
Referência: http://www.scriptbrasil.com.br/forum/topic/150561-resolvidophp-mysql-e-acentos/