mb_detect_encoding erra em texto curto: como tratar acento em PHP sem chutar
Entenda por que detectar encoding é palpite, troque mb_detect_encoding por uma verificação determinística com mb_check_encoding e teste o caminho com "ação" antes de converter qualquer coisa.
A importação de clientes roda à noite. De manhã, a cliente "Conceição" virou "Conceição" e o "João" virou "João". O código fazia o que parecia prudente: perguntava ao mb_detect_encoding qual era a codificação de cada nome e convertia para UTF-8. Com nomes de quatro ou cinco letras, a função respondeu errado, e a conversão estragou um texto que já estava certo.
A resposta curta: detectar encoding é palpite, não medição. Se você controla a origem, declare UTF-8 em todas as camadas e não detecte nada. Se o dado vem de fora, pergunte ao remetente qual é a codificação, e no código use mb_check_encoding($texto, 'UTF-8'), que é determinístico: se os bytes não formam UTF-8 válido, converta da codificação que você sabe ser a provável (no Brasil, quase sempre Windows-1252). E teste com "ação" antes de confiar.
Por que a detecção erra justo no acento
Um arquivo de texto é uma sequência de bytes. A codificação é a regra que diz como transformar esses bytes em letras, e ela não vem gravada no arquivo (exceto quando há um BOM no início, o que é raro fora do mundo Windows). A palavra "ação" em UTF-8 ocupa seis bytes: 61 c3 a7 c3 a3 6f. A mesma palavra em ISO-8859-1 ou Windows-1252 ocupa quatro: 61 e7 e3 6f. Olhando só os bytes, a função precisa adivinhar qual regra o autor usou.
O primeiro problema é que ISO-8859-1 aceita qualquer sequência de bytes. Todo byte de 0 a 255 corresponde a algum caractere, então nenhum texto é "inválido" em Latin-1. Se ele estiver na lista de candidatas, sempre há uma resposta possível que não é UTF-8, e o resultado depende da ordem da lista, do modo estrito e da heurística da versão do PHP que você está rodando.
O segundo problema é o tamanho. A heurística compara padrões estatísticos dos bytes, e um nome curto tem poucos bytes para comparar. Um texto de três parágrafos dá pistas suficientes; "João" não. É exatamente onde está o acento que você queria salvar.
Como a conversão dupla aparece
Quando a função diz que um texto UTF-8 é Latin-1 e você converte de Latin-1 para UTF-8, cada byte de acento é tratado como um caractere separado e recodificado. O "ç" (c3 a7) vira "Ã" e "§". O resultado é o famoso "ação", agora com dez bytes:
<?php
$texto = 'ação'; // arquivo-fonte salvo em UTF-8
echo bin2hex($texto), PHP_EOL;
// 61c3a7c3a36f
$estragado = mb_convert_encoding($texto, 'UTF-8', 'ISO-8859-1');
echo $estragado, PHP_EOL;
// ação
echo bin2hex($estragado), PHP_EOL;
// 61c383c2a7c383c2a36f
Esse padrão (letra acentuada virando "Ã" seguido de outro símbolo) é a assinatura de UTF-8 lido como Latin-1. Quando você vê isso no banco, não é falta de conversão: é conversão a mais.
A verificação que não chuta: mb_check_encoding
Em vez de perguntar "qual é a codificação?", pergunte "isto é UTF-8 válido?". UTF-8 tem regras rígidas: um byte c3 precisa ser seguido de um byte entre 80 e bf, e assim por diante. Texto em Windows-1252 com acentos quase nunca satisfaz essas regras por acaso. O byte e7 do "ç" em Latin-1, por exemplo, anuncia uma sequência de três bytes que não vem. Então:
- Se
mb_check_encodingdiz que não é UTF-8 válido, você tem certeza de que não é UTF-8. - Se diz que é válido, é UTF-8 em praticamente todos os casos reais de texto em português. A exceção seria um texto Latin-1 contendo literalmente sequências como "é", o que não acontece em nome de cliente.
Como implementar a conversão segura, passo a passo
- Descubra a codificação declarada. Pergunte ao sistema de origem, leia o cabeçalho
Content-Typecomcharsetse veio por HTTP, ou consulte a documentação da exportação. - Remova o BOM do UTF-8 se ele existir no início do arquivo, para ele não grudar no primeiro campo.
- Verifique se os bytes são UTF-8 válido. Se forem, não toque.
- Se não forem, converta da codificação provável, que você escolheu com base no passo 1, e não por detecção.
- Faça isso uma única vez, na entrada. Depois desse ponto, todo o sistema trabalha em UTF-8.
- Teste com palavras acentuadas curtas nos dois formatos antes de rodar a importação real.
<?php
declare(strict_types=1);
function paraUtf8(string $bytes, string $origemProvavel = 'Windows-1252'): string
{
$bom = pack('H*', 'EFBBBF');
if (str_starts_with($bytes, $bom)) {
$bytes = substr($bytes, 3);
}
if (mb_check_encoding($bytes, 'UTF-8')) {
return $bytes;
}
return mb_convert_encoding($bytes, 'UTF-8', $origemProvavel);
}
Por que Windows-1252 e não ISO-8859-1? As duas coincidem na maior parte da tabela, mas a faixa de bytes 80 a 9f é diferente: no Windows-1252 ela contém aspas curvas, travessão e o símbolo do euro, que aparecem com frequência em planilhas exportadas pelo Excel no Windows. Converter esses bytes como ISO-8859-1 produz caracteres de controle invisíveis em vez de aspas.
O teste que vale mais que a documentação
<?php
require __DIR__ . '/paraUtf8.php';
$casos = ['ação', 'ã', 'é', 'João', 'Conceição', 'Ç'];
foreach ($casos as $original) {
$emWindows1252 = mb_convert_encoding($original, 'Windows-1252', 'UTF-8');
if (paraUtf8($original) !== $original) {
throw new RuntimeException("UTF-8 alterado: $original");
}
if (paraUtf8($emWindows1252) !== $original) {
throw new RuntimeException("Windows-1252 não convertido: $original");
}
}
echo "Todos os casos passaram", PHP_EOL;
Os casos de uma letra só ("ã", "é") são os mais duros para qualquer heurística, e passam aqui porque a verificação não depende de estatística. Rode esse teste no mesmo servidor e na mesma versão do PHP que vão processar o arquivo.
Quando o dado é seu, não detecte nada
Se o texto foi gravado pelo seu próprio sistema, a codificação não é mistério: é a que você configurou. A conexão com o banco declara utf8mb4, as páginas mandam charset=utf-8, os arquivos-fonte estão em UTF-8. Detectar encoding nesse cenário só abre espaço para o erro. Se os acentos já quebram no seu próprio fluxo, o problema é uma camada configurada diferente das outras, e o caminho está em acento quebrado é encoding diferente em cada camada e em acentos que quebram no banco.
E não tente consertar dado já corrompido com detecção. Primeiro pare de corromper: corrija a camada que converte a mais e confirme que registros novos chegam certos. Só depois, com backup, avalie desfazer a conversão dupla nos registros antigos, testando numa amostra e conferindo à mão. Uma correção em massa feita no escuro pode estragar de vez os registros que estavam certos.
Erros comuns
- Colocar ISO-8859-1 antes de UTF-8 na lista do
mb_detect_encoding. Como Latin-1 aceita qualquer byte, o resultado pode apontar Latin-1 para texto UTF-8 e a conversão produz "ç". - Converter em duas camadas. O script converte na leitura e o banco converte de novo por causa de um charset de conexão errado; cada camada achava que era a única.
- Confiar no teste com texto longo. O parágrafo de exemplo passa, e os nomes de quatro letras, que são a maioria no cadastro, falham.
- Usar
utf8_encodeeutf8_decode. Elas só sabem converter de e para ISO-8859-1 e foram descontinuadas no PHP 8.2; o código antigo que depende delas precisa ser revisado. - Ignorar o BOM. O primeiro cabeçalho do CSV ganha um caractere invisível (U+FEFF) grudado antes de "nome", e a coluna "nome" não é encontrada.
- Consertar em massa antes de parar a causa. Registros novos continuam chegando corrompidos enquanto você corrige os antigos.
Checklist
- Você sabe, por documentação ou pergunta, qual codificação cada origem externa usa.
- A conversão acontece num único ponto, na entrada do dado.
- O código usa
mb_check_encodingpara decidir, não um palpite de detecção. - A codificação de fallback é explícita e escolhida com base na origem (Windows-1252 para exportações do Excel no Windows).
- O BOM é removido antes de interpretar o primeiro campo.
- Há um teste com "ação", "ã" e "é" em UTF-8 e em Windows-1252.
- Dados gravados pelo seu próprio sistema não passam por detecção.
Pegue o último arquivo importado no seu sistema e rode a palavra "ação" pelo mesmo caminho de leitura e conversão: ela sai do outro lado com quatro letras ou com "çã"?
Referência: https://onlinephp.io/mb-detect-encoding