strlen('ação') devolve 6, não 4. A função não está errada — ela só não conta o que você imagina: strlen conta bytes, mb_strlen conta caracteres. Em UTF-8, letras acentuadas ocupam mais de um byte cada, então qualquer texto em português faz as duas contagens divergirem. E o problema vai além do número: cortar com substr pode partir uma letra ao meio e produzir o famoso �. Este artigo mostra o mecanismo, quais funções têm par mb_*, como diagnosticar, e — importante — quando strlen continua sendo a escolha certa, porque trocar tudo cegamente também é erro.
O mecanismo: UTF-8 tem letras de tamanhos diferentes
Em UTF-8, o número de bytes por caractere é variável. Letras do ASCII (a–z, 0–9) ocupam 1 byte; letras acentuadas do português ocupam 2; símbolos e emojis podem ocupar 3 ou 4. Dá para ver isso com bin2hex, que mostra os bytes crus da string:
$s = 'ação';
echo strlen($s); // 6 ← bytes
echo mb_strlen($s); // 4 ← caracteres
echo bin2hex($s); // 61 c3a7 c3a3 6f
// a ç ã o
// 1B 2B 2B 1B = 6 bytes, 4 letras
strlen foi criada quando 1 byte = 1 caractere era verdade (era o mundo ASCII). Ela continua fazendo exatamente aquilo: medir o tamanho da string em bytes. Não é uma função "quebrada" — é uma função que responde outra pergunta. A família mb_* (de multibyte string) sabe interpretar UTF-8 e responde a pergunta que você geralmente quer: quantas letras.
O corte: onde nasce o �
Contar errado é chato; cortar errado é visível para o usuário. O substr também trabalha em bytes, então ele corta na posição N de bytes — e essa posição pode cair no meio de uma letra. Sobra um pedaço de byte que não forma caractere válido, e o navegador desenha �:
Corte em 1 byte
Pega o a inteiro (1 byte). Coincide com a letra, então parece tudo bem.
substr('ação', 0, 1); // "a" bytes: 61
mb_substr('ação', 0, 1);// "a"
Corte em 2 bytes — quebra
Pega o a e metade do ç (que precisa de 2 bytes: c3 a7). O meio byte c3 sozinho não é caractere.
substr('ação', 0, 2); // "a�" bytes: 61 c3 ← quebrou
mb_substr('ação', 0, 2);// "aç"
Corte em 3 bytes — coincide de novo
Aqui a conta bate por acaso: a + ç completo. É isso que torna o bug traiçoeiro — ele aparece e some conforme o texto.
substr('ação', 0, 3); // "aç" bytes: 61 c3a7
mb_substr('ação', 0, 3);// "açã"
Corte em 4 bytes — quebra outra vez
a + ç + metade do ã. De novo o �.
substr('ação', 0, 4); // "aç�" bytes: 61 c3a7 c3
mb_substr('ação', 0, 4);// "ação"
A conclusão
substr conta posições de byte; mb_substr conta posições de caractere e nunca parte uma letra ao meio. Em texto com acento, só o segundo é previsível.
mb_substr($texto, 0, 100); // sempre 100 letras, sempre íntegro
Num caso real — gerar o resumo de um texto — a diferença fica óbvia:
$frase = 'Descrição do produto';
echo strlen($frase); // 22 (bytes) — mas são 20 letras
echo substr($frase, 0, 12); // "Descrição " ← só 10 letras
// os acentos "comeram" 2 posições do corte$frase = 'Descrição do produto';
echo mb_strlen($frase); // 20 — letras de verdade
echo mb_substr($frase, 0, 12); // "Descrição do" ← 12 letrasNão é só contar e cortar: a família inteira
O mesmo raciocínio vale para todas as funções de string "clássicas" — elas operam byte a byte. Três exemplos que costumam passar despercebidos:
$s = 'ação';
echo strtoupper($s); // "AçãO" ← o ç e o ã ficam de fora
echo mb_strtoupper($s); // "AÇÃO"
echo str_pad($s, 6, '.'); // "ação" ← acha que já tem 6 (bytes!) e não completa
echo mb_str_pad($s, 6, '.'); // "ação.." (PHP 8.3+)
echo strrev($s); // "o?ç?a" ← inverte BYTES e destrói os acentos
O strtoupper é especialmente enganoso porque o resultado parece funcionar: metade das letras sobe, os acentos não. E o str_pad mostra que o problema não é só "contar" — a contagem errada contamina qualquer função que dependa de tamanho.
| Se você usa… | Troque por… | Para quê |
|---|---|---|
strlen |
mb_strlen |
Contar letras |
substr |
mb_substr |
Cortar sem quebrar |
strtoupper / strtolower |
mb_strtoupper / mb_strtolower |
Caixa com acento |
strpos / stripos |
mb_strpos / mb_stripos |
Posição em letras |
str_split |
mb_str_split |
Separar em letras |
str_pad |
mb_str_pad (PHP 8.3+) |
Alinhar/preencher |
strrev |
(não tem par — use mb_str_split + array_reverse + implode) |
Inverter |
Diagnóstico: descobrir se o problema é esse
Duas checagens rápidas resolvem a dúvida "será que é isso que está acontecendo comigo?":
// 1. Se os dois números divergem, a string tem caracteres multibyte
var_dump(strlen($texto), mb_strlen($texto));
// 2. Ver os bytes crus e onde estão os multibyte
echo wordwrap(bin2hex($texto), 2, ' ', true);
Se strlen > mb_strlen, qualquer lógica sua baseada em strlen/substr está operando com números diferentes do que o usuário vê. Vale checar especialmente validações de formulário: um limite de "máximo 20 caracteres" implementado com strlen rejeita nomes acentuados antes da conta fechar.
Um campo valida if (strlen($nome) > 20) { erro(); }. O usuário digita "João Conceição Sá" (18 letras). O que acontece?
Ver resposta
Resposta certa: É rejeitado — para o strlen a string tem 22
Os quatro acentos (ã, ç, ã, á) ocupam 2 bytes cada, somando 4 bytes extras: 18 letras + 4 = 22 bytes. A validação por bytes reprova um nome perfeitamente válido, e o usuário não entende por quê — a mensagem diz "máximo 20 caracteres" e ele contou 18. Valide com mb_strlen. É por isso que este bug costuma chegar como reclamação de usuário com nome acentuado, e não como erro no log.
Quando strlen continua certo
Aqui está a parte que quase nenhum tutorial diz: não saia trocando todo strlen por mb_strlen. Os dois respondem perguntas diferentes, e às vezes a pergunta certa é mesmo sobre bytes:
- Limite de armazenamento. Uma coluna
VARCHARdo MySQL emutf8mb4reserva espaço por caractere, mas limites de bytes (como o tamanho máximo de uma chave de índice) são medidos em bytes — aístrlené a medida correta. - Dados binários. Conteúdo de arquivo, imagem, hash: não são texto, e contar "caracteres" não faz sentido. Use
strlen. - Protocolos e cabeçalhos.
Content-Lengthé em bytes, sempre. - Performance em ASCII garantido. Se a string é comprovadamente ASCII (um UUID, um hash hexadecimal, um código gerado por você),
strlené mais rápida e igualmente correta.
A regra prática que resume tudo: texto que veio de gente (ou vai para gente) → mb_*; bytes que vão para disco, rede ou memória → strlen.
A camada seguinte: caractere ≠ o que você vê
Já que você chegou até aqui, vale saber que mb_strlen também tem um limite — ela conta code points, que nem sempre correspondem ao que aparece na tela. Duas situações reais:
$pre = "é"; // 1 code point (U+00E9) → mb_strlen = 1
$comp = "e\u{0301}"; // "e" + acento combinante → mb_strlen = 2
var_dump($pre === $comp); // false — e os dois aparecem como "é" na tela!
As duas formas são visualmente idênticas, mas são strings diferentes — é a explicação para o clássico "o usuário jura que digitou o mesmo nome, mas a busca não acha". Quando o texto vem de fontes variadas (macOS, colagem de PDF, APIs), normalize antes de comparar ou salvar, com a extensão intl:
$normalizado = Normalizer::normalize($texto, Normalizer::FORM_C);
O mesmo vale para emojis compostos: uma família 👨👩👧 é um símbolo na tela, mas mb_strlen conta 5 (são vários code points unidos por junções invisíveis). Quando o que importa é o que o olho enxerga, a medida correta é o grapheme: grapheme_strlen(), também da intl.
Resumo de decisão
| Situação | Use | Por quê |
|---|---|---|
| Contar letras de texto do usuário | mb_strlen |
strlen conta bytes e infla com acento |
| Cortar resumo/preview | mb_substr |
substr parte a letra e gera � |
| Validar tamanho de campo | mb_strlen |
Senão rejeita nome acentuado válido |
| Maiúscula/minúscula com acento | mb_strtoupper / mb_strtolower |
As versões byte ignoram acentos |
Medir arquivo, hash, Content-Length |
strlen |
A pergunta é mesmo sobre bytes |
| Comparar textos de fontes diferentes | Normalizer::normalize antes |
"é" pode ser 1 ou 2 code points |
| Contar o que o usuário vê (emoji) | grapheme_strlen (intl) |
Um símbolo pode ter vários code points |
A seção de funções multibyte lista a família mb_* inteira — contagem, corte, busca, caixa, conversão e divisão. Conhecer os pares completos é o que blinda seu código contra texto acentuado.
Entender que uma string em PHP é uma sequência de bytes (e não de caracteres) é a raiz de tudo o que este artigo explica — do strlen inflado ao corte que gera �.