Programação Por Equipe DevBras

Capa do artigo: Acento quebrado no PHP: confira o UTF-8 camada por camada, do arquivo ao banco

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 latin1 ou na antiga utf8 de 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.

  1. 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.php mostra o charset detectado.
  2. Cabeçalho HTTP. É ele que manda no navegador. No php.ini, default_charset = "UTF-8" faz o PHP enviar charset=UTF-8 no Content-Type, e esse já é o padrão nas versões atuais. Para arquivos estáticos servidos pelo Apache, AddDefaultCharset UTF-8 no virtual host cobre HTML e texto puro que saírem sem charset.
  3. Tag meta: <meta charset="utf-8"> como primeiro elemento dentro do head. Ela vale quando a página é aberta do disco ou salva, e o navegador precisa encontrá-la logo no começo do documento.
  4. Conexão com o MySQL. A conexão tem encoding próprio, independente da coluna. Se ela estiver em latin1 e a coluna em UTF-8, o MySQL converte no caminho e é aí que nasce a dupla codificação.
  5. Coluna e tabela em utf8mb4. No MySQL, utf8 é um apelido antigo para utf8mb3, que não guarda emoji nem alguns caracteres raros. Use utf8mb4.
  6. Funções de string. strlen conta bytes, não letras. Para cortar ou contar texto, use mb_strlen e mb_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_encode e utf8_decode pelo 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 utf8 no 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 TO em 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, a meta perde.

Checklist

  • Todos os arquivos do projeto estão em UTF-8 sem BOM.
  • curl -sI mostra charset=UTF-8 no Content-Type.
  • <meta charset="utf-8"> é o primeiro item do head.
  • A conexão declara charset=utf8mb4 no DSN ou em set_charset.
  • SHOW CREATE TABLE mostra utf8mb4 nas colunas de texto.
  • HEX(nome) de um registro com "ç" devolve C3A7, não C383C2A7.
  • Não sobrou nenhum utf8_encode ou utf8_decode no 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?

Avalie este artigo

Referência: http://www.nerdblog.info/2010/03/30/como-mudar-o-charset-para-aplicacoes-web-com-apache-e-php/