Construir um site multilingue em PHP sem framework
Como servimos EN/PT/ES a partir de uma única base de código PHP renderizada no servidor — encaminhamento por prefixo de URL, dicionário de strings, hreflang e a armadilha da deteção de idioma que recusámos.
É possível construir um site totalmente multilingue em PHP sem framework, sem passo de build e sem uma tabela de base de dados por idioma. Servimos EN/PT/ES a partir de uma única base de código PHP renderizada no servidor usando três peças pequenas: encaminhamento por prefixo de URL (/pt/, /es/), um dicionário de strings plano e i18n por campo no CMS — além de hreflang correto e uma regra teimosa sobre deteção de idioma.
Versão curta: o idioma vive no URL, nunca num cookie ou num palpite. O idioma por omissão não tem prefixo. Tudo o resto é uma camada fina de funções auxiliares por cima de PHP simples.
Porquê um site multilingue em PHP sem framework
O site do estúdio corre em PHP 8.3 em alojamento partilhado da OVH, atrás da Cloudflare, com o Cockpit CMS v2 (auto-alojado, SQLite) para conteúdos. Sem Laravel, sem Symfony, sem pipeline de build em Node. As páginas são renderizadas no servidor e entregues tal como estão. Essa restrição é deliberada: menos peças móveis significam um site mais rápido, uma superfície de ataque menor e uma base de código que um único engenheiro consegue ter na cabeça.
A internacionalização costuma ser onde o "sem framework" assusta as pessoas, porque os ecossistemas de frameworks vendem catálogos de mensagens, middleware de locale e tradução de rotas como um problema resolvido. Acontece que a área de superfície real é pequena. Um site multilingue precisa de responder a quatro perguntas: que idioma é este pedido?, como escrevo um link que se mantém nesse idioma?, como traduzo uma string? e como digo ao Google que estas páginas são o mesmo conteúdo em idiomas diferentes? Cada uma é uma função ou duas.
1. O idioma vive no prefixo do URL
O nosso esquema de URLs é toda a fundação:
/work→ Inglês (por omissão, sem prefixo)/pt/work→ Português/es/work→ Espanhol
O Inglês não leva prefixo. Esta é uma decisão real com consequências: mantém os URLs canónicos/por omissão limpos, mas significa que o Inglês nunca pode ser "só mais um idioma" no código — é sempre o ramo de fallback. Achamos esse compromisso correto para um estúdio sediado em Portugal cujo público por omissão lê Inglês.
Em produção (Apache/OVH) o prefixo é removido no .htaccess e o idioma é passado como parâmetro de query para o mesmo ficheiro PHP que teria servido o Inglês:
# prefixo de URL i18n: /pt/<resto> ou /es/<resto> → remove prefixo, passa lang na QS
RewriteRule ^(pt|es)$ $1/ [R=302,L]
RewriteRule ^(pt|es)/(.*)$ $2?lang=$1 [QSA,L]
RewriteRule ^(pt|es)/?$ ?lang=$1 [QSA,L]
Assim, /pt/work torna-se internamente work.php?lang=pt. Um template, três idiomas. Não existe nenhuma pasta /pt/ em disco — e essa ordenação importa enormemente, como veremos.
Para desenvolvimento local com php -S não há .htaccess, por isso um router.php espelha a mesma regra em PHP antes de qualquer encaminhamento:
// Deteta prefixo /pt/ /es/ → remove + define $_GET['lang']
if (preg_match('#^/(pt|es)(/.*|$)#', $uri, $lm)) {
$_GET['lang'] = $lm[1];
$uri = $lm[2] === '' ? '/' : $lm[2];
}
Manter o router de dev e os rewrites de produção em sintonia é a fonte mais comum de bugs de i18n do tipo "funciona local, falha em produção". Tratamo-los como uma única regra lógica expressa em dois dialetos.
2. Uma função para ler o idioma
Todos os templates precisam de saber o idioma atual. Isso é uma função em cache em lib/lang.php. Confia no parâmetro de query que o rewrite definiu e, se este faltar, volta a analisar o caminho do URL como fallback de segurança:
function lang(): string {
static $cached = null;
if ($cached !== null) return $cached;
$l = (string)($_GET['lang'] ?? '');
if ($l === '') {
$uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
if (preg_match('#^/(pt|es)(/|$)#', $uri ?? '', $m)) $l = $m[1];
}
if (!in_array($l, ['en','pt','es'], true)) $l = 'en';
return $cached = $l;
}
A última linha é a fronteira de segurança e de correção: tudo o que não seja um locale na lista permitida colapsa para Inglês. Nenhuma string fornecida pelo utilizador chega a um caminho de ficheiro ou a uma chave de dicionário sem verificação.
3. Links que se mantêm no idioma atual
A armadilha em qualquer esquema de prefixos é escrever /pt/work à mão nos templates. Faça isso uma vez e o link de um tradutor atira um leitor português de volta para o site em Inglês. Em vez disso, todos os links internos passam por link_to(), que prefixa consoante o locale atual e recusa duplicar o prefixo ou tocar em URLs externos:
function link_to(string $path): string {
$l = lang();
if (preg_match('#^(https?:|mailto:|tel:|//)#', $path)) return $path; // externo
if (str_starts_with($path, '#')) return $path; // âncora
// já prefixado? deixa estar
foreach (['pt','es'] as $sup) {
if (str_starts_with($path, '/'.$sup.'/') || $path === '/'.$sup) return $path;
}
if ($l === 'en') return $path; // EN = sem prefixo
if ($path === '/' || $path === '') return '/'.$l.'/';
return '/'.$l.$path;
}
Os templates escrevem sempre link_to('/work'). Em Inglês obtêm /work; em Português obtêm /pt/work; e um /pt/work passado por engano fica intacto em vez de ser corrompido para /pt/pt/work. A idempotência é o ponto-chave.
4. Dois tipos de tradução: cromo vs. conteúdo
O conteúdo multilingue divide-se nitidamente em duas categorias, e cada uma quer um armazenamento diferente.
Cromo de interface — etiquetas de navegação, texto de botões, o rodapé, a página 404 — é finito, vive no código e raramente muda. Guardamo-lo num único array PHP plano em lib/ui-strings.php, indexado por idioma e depois por uma chave de string com pontos, e lemo-lo com t():
function t(string $key, ?string $lang = null): string {
static $dict = null;
if ($dict === null) $dict = require __DIR__ . '/ui-strings.php';
$lang = $lang ?? lang();
return $dict[$lang][$key] ?? $dict['en'][$key] ?? $key;
}
A cadeia de fallback é tudo: idioma pedido → Inglês → a própria chave. Uma string portuguesa em falta degrada para Inglês, nunca para um vazio ou um crash; e uma chave com erro de escrita aparece como nav.work em vez de desaparecer, o que torna o bug ruidoso em vez de silencioso. O dicionário é um simples require — o cache de opcode do PHP mantém-no em memória, por isso não há custo de parsing por pedido nem I/O.
Conteúdo editorial — artigos do journal, casos de estudo, descrições de serviços — é aberto e editado por pessoas, por isso vive no Cockpit. Em vez de duplicar registos inteiros por idioma, guardamos traduções por campo: um campo é ou uma string simples (igual em todos os idiomas) ou um pequeno mapa como {"en": "...", "pt": "...", "es": "..."}. Um auxiliar tr() resolve qualquer das formas:
function tr($value, ?string $lang = null) {
if (!is_array($value)) return $value; // não localizado → tal como está
$lang = $lang ?? lang();
return $value[$lang] ?? $value['en'] ?? reset($value);
}
É por isto que o artigo que está a ler tem title_en/title_pt/title_es e campos de corpo correspondentes: o mesmo registo, traduzido campo a campo, resolvido em tempo de renderização. Um slug, uma data ou uma imagem não precisam de tradução e mantêm-se um escalar simples. Acrescentar um quarto idioma mais tarde significa acrescentar uma chave aos mapas — não migrar um schema.
5. Dizer a verdade aos motores de busca com hreflang
Três URLs a servir o mesmo artigo em três idiomas é, para um crawler ingénuo, conteúdo duplicado. O hreflang é a forma padrão de dizer "estes são traduções uns dos outros, serve o certo." Cada página emite o conjunto recíproco completo a partir de lib/seo.php, depois de remover qualquer prefixo existente para nunca compor /pt/pt/:
$rootPath = preg_replace('#^/(pt|es)(/|$)#', '/', $path);
$ptPath = $rootPath === '/' ? '/pt/' : '/pt' . $rootPath;
$esPath = $rootPath === '/' ? '/es/' : '/es' . $rootPath;
// →
As regras a que nos atemos:
- Reciprocidade. Cada locale liga a todos os outros locales, incluindo a si próprio. O Google ignora
hreflangunidirecional. - O
x-defaultaponta para Inglês. É o alvo "não temos o seu idioma, aqui está a escolha segura" — e o nosso por omissão não tem prefixo, por isso é o caminho nu. - O canónico é autorreferencial, nunca cruza locales. O canónico da página portuguesa é o URL português. Apontar os três para o canónico inglês diria ao Google que as páginas PT/ES não deviam ser indexadas de todo — um erro clássico que mata tráfego.
- O mesmo conjunto no sitemap. Emitimos lá os mesmos alternates
xhtml:link, para que o sinal seja consistente nos dois canais.
6. A armadilha que recusámos: redirecionar automaticamente por navigator.language
Eis a que fizemos mal primeiro, corrigimos e sobre a qual agora temos uma opinião firme. A ideia "prestável" é: na primeira visita, ler o idioma do browser e atirar o utilizador automaticamente para /pt/ ou /es/. Enviámos exatamente isso, lendo navigator.language.slice(0,2) no nosso JS de arranque e redirecionando.
Foi um desastre silencioso. O navigator.language reflete a lista de preferências do browser, não o país ou a intenção do utilizador. Visitantes em Portugal com Espanhol em qualquer ponto das preferências do browser eram forçados para /es/ no primeiro carregamento. Pior, o redirecionamento escrevia a escolha em localStorage, tornando o locale errado persistente entre sessões sem saída óbvia — a página inicial passava a abrir num idioma que não pediram, sempre.
Arrancámos isso. O prefixo de URL é agora o único caminho para um locale não-inglês no arranque, e mudámos a chave de armazenamento (amp-lang → amp-lang-v2) para invalidar os valores presos já guardados nos browsers de visitantes recorrentes.
O princípio: deixe o utilizador escolher o idioma explicitamente e torne a escolha visível no URL. Um URL /pt/ limpo, partilhável e marcável bate um palpite esperto sempre. Se quiser pistas ao nível do país, mostre um banner dispensável — nunca um redirecionamento silencioso que sequestra a barra de endereço. (O Accept-Language e a Content-Negotiation do lado do servidor têm exatamente o mesmo modo de falha; também não os usamos para encaminhamento.)
7. O bug de ordenação que apanha toda a gente
Um último detalhe arduamente aprendido. No .htaccess, o rewrite do prefixo i18n tem de correr antes da verificação de existência de ficheiro. Este site tem anos de URLs legados de WordPress, alguns sob caminhos literais /pt/ e /es/ do CMS antigo. Se a passagem do Apache "este ficheiro/pasta existe?" corresse primeiro, essas pastas fantasma sombreariam o rewrite e serviriam conteúdo obsoleto em vez de encaminhar para o template localizado. Os redirecionamentos legados 301/410 ficam à frente de ambos, por isso um URL antigo morto é reformado antes de qualquer lógica de locale lhe tocar. A ordem das regras no .htaccess não é cosmética — é o fluxo de controlo.
Recapitulação em FAQ
Precisa de uma framework para um site multilingue em PHP? Não. Idioma-a-partir-do-URL, uma consulta a dicionário, um auxiliar de links e hreflang correto são, ao todo, algumas dezenas de linhas.
Onde deve viver o idioma? No caminho do URL. Não num cookie, numa sessão ou num palpite sobre o idioma do browser. Os URLs mantêm-se partilháveis e os motores de busca conseguem indexar cada idioma.
Como evitar penalizações por conteúdo duplicado? hreflang recíproco em cada página, x-default para o seu idioma por omissão e canónicos autorreferenciais por locale.
Deve detetar automaticamente o idioma do utilizador? Não redirecione com base nisso. O navigator.language e o Accept-Language descrevem preferências, não intenção, e adivinhar mal é persistente e irritante. Deixe o URL decidir.