ç no lugar de ç: como consertar o dado corrompido uma vez só no MySQL
Como identificar registros com acento duplamente codificado, testar a correção com SELECT, aplicar o UPDATE só onde precisa e tirar o utf8_decode espalhado pelas páginas.
A loja tem 12 mil clientes cadastrados. Os antigos aparecem como "João" e "Conceição". Alguém resolveu com um utf8_decode() no echo da listagem. Os antigos ficaram certos. Os novos, que já nasciam corretos, passaram a aparecer como "Jo?o". Aí outra pessoa colocou um if para decidir quando decodificar. Hoje ninguém sabe qual registro está em qual estado.
A resposta curta: "ç" é UTF-8 lido como Latin-1 e gravado de novo como UTF-8. Converter na saída não conserta, só empilha camadas. Primeiro faça a cadeia inteira falar UTF-8, para nenhum registro novo nascer errado. Depois conserte os registros antigos uma vez, no banco, com um UPDATE testado em SELECT, filtrado só para as linhas quebradas e com backup antes. Por fim, apague todas as funções de conversão da saída.
O que aconteceu com os bytes
A letra "ç" em UTF-8 ocupa dois bytes: C3 A7. Em Latin-1, cada byte é uma letra: C3 é "Ã" e A7 é "§". Quando uma camada manda bytes UTF-8 e a outra os interpreta como Latin-1, o "ç" vira "ç". Se esse texto errado for gravado numa coluna UTF-8, o banco guarda, com toda fidelidade, os caracteres "Ã" e "§", agora com quatro bytes: C3 83 C2 A7.
O caso típico em sistema PHP antigo: a tabela foi criada em latin1, a conexão nunca declarou charset, e o formulário mandava UTF-8. Depois alguém migrou a coluna para utf8mb4 com ALTER TABLE ... CONVERT TO. O MySQL converteu fielmente o que achou que era Latin-1, e o mojibake ficou petrificado em UTF-8 válido.
Se a história se repete, o texto é codificado duas vezes e o "ç" vira "ç". Cada nova "correção" aplicada às cegas acrescenta uma camada. Por isso o título: o dado corrompido se conserta uma vez, com método, e não a cada exibição.
Passo 1: pare de produzir dado novo errado
Antes de mexer no que existe, garanta que um cadastro feito hoje nasce certo. Isso envolve quatro pontos: arquivos PHP salvos em UTF-8, cabeçalho HTTP com charset=utf-8, conexão com charset=utf8mb4 e colunas em utf8mb4. O passo a passo dessas camadas está em acento quebrado é encoding diferente em cada camada e em acentos que quebram só quando vêm do MySQL. Este guia cuida do que já foi gravado errado.
Teste: cadastre "Conceição Ávila" pelo formulário, sem nenhuma função de conversão no caminho, e confira no banco:
SELECT nome, HEX(nome) FROM clientes ORDER BY id DESC LIMIT 1;
O "ç" tem que aparecer como C3A7 no hexadecimal. Se aparecer C383C2A7, a cadeia ainda está quebrada e consertar os antigos agora só cria mais lixo amanhã.
Passo 2: encontre os registros quebrados
Os sinais de UTF-8 lido como Latin-1 são sequências começando por "Ã" ou "Â" seguidas de outro símbolo. Cuidado com a collation: em utf8mb4_0900_ai_ci, que ignora acento, "Ã" é igual a "A" e o LIKE casa quase tudo. Force comparação binária:
SELECT id, nome
FROM clientes
WHERE nome COLLATE utf8mb4_bin LIKE '%Ã%'
OR nome COLLATE utf8mb4_bin LIKE '%Â%'
LIMIT 50;
Na loja do exemplo, a consulta devolveu 3.140 linhas. Olhe uma amostra com atenção: um nome estrangeiro legítimo pode conter "Ã" de verdade. Raro em cadastro brasileiro, mas acontece, e é por isso que a correção precisa de olho humano.
Passo 3: teste a correção com SELECT
A reversão faz o caminho inverso: transforma o texto em Latin-1 (recuperando os bytes originais), trata como binário e reinterpreta como UTF-8.
SELECT id,
nome AS atual,
CONVERT(CAST(CONVERT(nome USING latin1) AS BINARY) USING utf8mb4) AS corrigido
FROM clientes
WHERE nome COLLATE utf8mb4_bin LIKE '%Ã%'
OR nome COLLATE utf8mb4_bin LIKE '%Â%'
LIMIT 50;
Leia a coluna corrigido. "João" deve virar "João", "Conceição" deve virar "Conceição". Se aparecer "ã" ainda, aquela linha foi codificada duas vezes e precisa da conversão aplicada mais uma vez, em separado. Se aparecer vazio, NULL ou texto cortado, a linha não era mojibake desse tipo e não pode entrar no UPDATE.
Um detalhe: o latin1 do MySQL na prática é o cp1252 do Windows, que tem aspas curvas e travessão nas posições onde o Latin-1 puro tem caracteres de controle. Isso costuma jogar a favor, porque o texto colado do Word passou pelo mesmo caminho, mas confirme na amostra.
Passo 4: aplique uma vez, com rede de segurança
- Faça backup da tabela e confirme que ele restaura (veja backup de MySQL que você consegue restaurar).
- Copie as linhas filtradas para uma tabela de apoio, com o valor antigo.
- Apague da tabela de apoio os ids que, no
SELECT, não ficaram certos: nomes legítimos com "Ã", linhas com dupla codificação, resultados vazios ou cortados. Esses você trata à parte. - Rode o
UPDATEsó para os ids que sobraram na tabela de apoio. - Rode a busca do passo 2 de novo e confira o que sobrou, um a um.
CREATE TABLE clientes_nome_antes AS
SELECT id, nome FROM clientes
WHERE nome COLLATE utf8mb4_bin LIKE '%Ã%'
OR nome COLLATE utf8mb4_bin LIKE '%Â%';
-- depois de revisar a amostra:
DELETE FROM clientes_nome_antes WHERE id IN (812, 4410);
UPDATE clientes c
JOIN clientes_nome_antes a ON a.id = c.id
SET c.nome = CONVERT(CAST(CONVERT(a.nome USING latin1) AS BINARY) USING utf8mb4);
Rode primeiro numa cópia do banco. Repita para cada coluna de texto afetada: nome, endereço, observações. Quando tudo estiver conferido, apague a tabela de apoio.
Passo 5: arranque as conversões do código
Agora os dados estão certos e os novos nascem certos. Toda chamada a utf8_decode(), utf8_encode(), iconv() ou mb_convert_encoding() usada para "consertar a exibição" passou a estragar o que estava bom. Procure e remova:
grep -rnE "utf8_(de|en)code|iconv[(]|mb_convert_encoding" --include=*.php .
Nem toda ocorrência é para apagar: conversão ao ler um CSV exportado por um sistema em Windows-1252 é legítima. A regra é converter na fronteira, quando o dado entra, e nunca na saída. As funções utf8_decode e utf8_encode, aliás, estão marcadas como obsoletas desde o PHP 8.2.
Erros comuns
- Converter na hora de exibir: conserta os antigos e estraga os novos. Duas populações de dados convivendo para sempre.
- Rodar o UPDATE na tabela inteira: linhas que estavam certas viram lixo ou
NULL, porque não eram mojibake. - Consertar antes de arrumar a conexão: o cadastro de amanhã nasce quebrado e a busca nunca zera.
- Usar
LIKEcom collation que ignora acento: a busca devolve a tabela quase toda e você "corrige" o que não precisava. - Editar à mão por cima: alguém vê "ç" no painel, apaga e redigita no mesmo registro que o UPDATE também vai tocar. O resultado é imprevisível.
- Não guardar o valor anterior: se algo der errado, não há como comparar nem voltar sem restaurar o banco inteiro.
Checklist
- Um cadastro novo com "ç" aparece como
C3A7noHEX(). - A busca por "Ã" e "Â" usa comparação binária.
- A correção foi conferida por amostra no
SELECTantes doUPDATE. - Existe backup testado e uma tabela com os valores anteriores.
- O
UPDATEtocou só as linhas filtradas. - Não sobrou função de conversão na saída das páginas.
Rode agora o SELECT ... COLLATE utf8mb4_bin LIKE '%Ã%' na sua tabela de clientes: quantas linhas voltam, e alguma delas foi cadastrada esta semana?
Referência: http://wiki.locaweb.com/pt-br/Como_resolver_problemas_de_acentua%C3%A7%C3%B5es_em_seu_site