8 MIN LEITURA · Pedro Thomaz

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.
Construir um site multilingue em PHP sem framework

É 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:

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:

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-langamp-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.

Perguntas frequentes

É possível criar um site multilingue em PHP sem framework?

Sim. Servimos inglês, português e espanhol a partir de uma única base de código PHP 8.3 sem build, usando prefixos de URL (/pt/, /es/), uma reescrita em .htaccess que mapeia esses prefixos para uma query string ?lang= e uma pequena camada de helpers (lang(), link_to(), tr()). Não é preciso nenhuma biblioteca de routing nem pacote de i18n.

Qual é a melhor estrutura de URL para SEO num site multilingue?

Os prefixos em subdiretório (example.com/pt/) são a opção de menor atrito para um único domínio. Mantemos o inglês na raiz sem prefixo como predefinição, prefixamos apenas /pt/ e /es/ e emitimos uma tag por idioma, mais um x-default a apontar para a raiz em inglês.

Como funciona o hreflang e ainda preciso dele com subdiretórios?

Sim — o hreflang indica ao Google que URL serve cada idioma, para que mostre o correto nos resultados e não trate as traduções como conteúdo duplicado. Cada página tem de listar todas as variantes de idioma (incluindo a própria) e um x-default para utilizadores cujo idioma não é abrangido; o conjunto tem de ser recíproco entre todas as páginas.

Devo redirecionar os visitantes automaticamente com base no idioma do navegador?

Não. Removemos deliberadamente os redirecionamentos por navigator.language porque quebram links partilhados, confundem os crawlers e prendem o utilizador na localização errada. Deixe o URL ser a única fonte de verdade e ofereça antes um seletor de idioma visível.

É preciso traduzir todos os campos ou posso recorrer ao inglês?

O fallback campo a campo é a resposta pragmática para o site de um estúdio pequeno. O nosso helper tr() lê o valor localizado quando existe e recorre ao inglês caso contrário, por isso um artigo do journal traduzido a meio continua a renderizar bem em vez de mostrar campos em branco.

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.