Building a PHP multilingual site without a framework
How we serve EN/PT/ES from one server-rendered PHP codebase — URL-prefix routing, a string dictionary, hreflang, and the language-detection trap we refused.
You can build a fully multilingual PHP site without a framework, without a build step, and without a database table per locale. We run EN/PT/ES from a single server-rendered PHP codebase using three small pieces: URL-prefix routing (/pt/, /es/), a flat string dictionary, and per-field i18n in the CMS — plus correct hreflang and one stubborn rule about language detection.
The short version: locale lives in the URL, never in a cookie or a guess. The default language has no prefix. Everything else is a thin layer of helper functions on top of plain PHP.
Why a PHP multilingual site without a framework
The studio site runs on PHP 8.3 on OVH shared hosting, behind Cloudflare, with Cockpit CMS v2 (self-hosted, SQLite) for content. No Laravel, no Symfony, no Node build pipeline. Pages are server-rendered and shipped as-is. That constraint is deliberate: fewer moving parts means a faster site, a smaller attack surface, and a codebase a single engineer can hold in their head.
Internationalization is where "no framework" usually scares people, because the framework ecosystems sell you message catalogs, locale middleware, and route translation as a solved problem. It turns out the actual surface area is small. A multilingual site needs to answer four questions: what language is this request?, how do I write a link that stays in that language?, how do I translate a string?, and how do I tell Google these pages are the same content in different languages? Each one is a function or two.
1. Locale lives in the URL prefix
Our URL scheme is the whole foundation:
/work→ English (default, no prefix)/pt/work→ Portuguese/es/work→ Spanish
English carries no prefix. This is a real decision with consequences: it keeps the canonical/default URLs clean, but it means English can never be "just another locale" in the code — it's always the fallback branch. We think that tradeoff is correct for a Portugal-based studio whose default audience reads English.
On production (Apache/OVH) the prefix is stripped in .htaccess and the language is passed as a query param to the same PHP file that would have served English:
# i18n URL prefix: /pt/<rest> or /es/<rest> → strip prefix, pass lang in QS
RewriteRule ^(pt|es)$ $1/ [R=302,L]
RewriteRule ^(pt|es)/(.*)$ $2?lang=$1 [QSA,L]
RewriteRule ^(pt|es)/?$ ?lang=$1 [QSA,L]
So /pt/work internally becomes work.php?lang=pt. One template, three languages. There is no /pt/ directory on disk — and that ordering matters enormously, which we'll come back to.
For local development with php -S there's no .htaccess, so a router.php mirrors the same rule in PHP before any routing happens:
// Detect /pt/ /es/ prefix → strip + set $_GET['lang']
if (preg_match('#^/(pt|es)(/.*|$)#', $uri, $lm)) {
$_GET['lang'] = $lm[1];
$uri = $lm[2] === '' ? '/' : $lm[2];
}
Keeping the dev router and the production rewrites in lockstep is the single most common source of "works locally, breaks live" i18n bugs. We treat them as one logical rule expressed in two dialects.
2. One function to read the language
Every template needs to know the current language. That's one cached function in lib/lang.php. It trusts the query param the rewrite set, and if that's missing it re-parses the URL path as a belt-and-suspenders fallback:
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;
}
The last line is the security and correctness boundary: anything that isn't an allow-listed locale collapses to English. No user-supplied string ever reaches a file path or a dictionary key unchecked.
3. Links that stay in the current language
The trap in any prefix scheme is hand-writing /pt/work in templates. Do that once and a translator's link will dump a Portuguese reader back onto the English site. Instead, every internal link goes through link_to(), which prefixes based on the current locale and refuses to double-prefix or touch external URLs:
function link_to(string $path): string {
$l = lang();
if (preg_match('#^(https?:|mailto:|tel:|//)#', $path)) return $path; // external
if (str_starts_with($path, '#')) return $path; // anchor
// already prefixed? leave it
foreach (['pt','es'] as $sup) {
if (str_starts_with($path, '/'.$sup.'/') || $path === '/'.$sup) return $path;
}
if ($l === 'en') return $path; // EN = no prefix
if ($path === '/' || $path === '') return '/'.$l.'/';
return '/'.$l.$path;
}
Templates always write link_to('/work'). In English they get /work; in Portuguese they get /pt/work; and a stray /pt/work passed in by mistake is left untouched rather than mangled into /pt/pt/work. The idempotence is the point.
4. Two kinds of translation: chrome vs. content
Multilingual content splits cleanly into two categories, and they want different storage.
UI chrome — nav labels, button text, the footer, the 404 page — is finite, lives in code, and rarely changes. We keep it in a single flat PHP array in lib/ui-strings.php, keyed by language then by a dotted string key, and read it with 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;
}
The fallback chain is the whole ballgame: requested language → English → the key itself. A missing Portuguese string degrades to English, never to a blank or a crash; and a typo'd key renders as nav.work rather than disappearing, which makes the bug loud instead of silent. The dictionary is a plain require — PHP's opcode cache keeps it in memory, so there's no parsing cost per request and no I/O.
Editorial content — journal posts, case studies, service descriptions — is open-ended and edited by humans, so it lives in Cockpit. Rather than duplicate whole records per locale, we store translations per field: a field is either a plain string (same in every language) or a small map like {"en": "...", "pt": "...", "es": "..."}. One tr() helper resolves either shape:
function tr($value, ?string $lang = null) {
if (!is_array($value)) return $value; // not localized → as-is
$lang = $lang ?? lang();
return $value[$lang] ?? $value['en'] ?? reset($value);
}
This is why the journal post you're reading has title_en/title_pt/title_es and matching body fields: the same record, translated field by field, resolved at render time. A slug, a date, or an image needs no translation and stays a plain scalar. Adding a fourth language later means adding a key to maps — not migrating a schema.
5. Telling search engines the truth with hreflang
Three URLs serving the same article in three languages is, to a naive crawler, duplicate content. hreflang is the standard way to say "these are translations of each other, serve the right one." Every page emits the full reciprocal set from lib/seo.php, after stripping any existing prefix so we never compound /pt/pt/:
$rootPath = preg_replace('#^/(pt|es)(/|$)#', '/', $path);
$ptPath = $rootPath === '/' ? '/pt/' : '/pt' . $rootPath;
$esPath = $rootPath === '/' ? '/es/' : '/es' . $rootPath;
// →
The rules we hold to:
- Reciprocity. Every locale links to every other locale, including itself. Google ignores one-directional
hreflang. x-defaultpoints at English. It's the "we don't have your language, here's the safe choice" target — and our default has no prefix, so it's the bare path.- Canonical is self-referential, never cross-locale. The Portuguese page's canonical is the Portuguese URL. Pointing all three at the English canonical would tell Google the PT/ES pages shouldn't be indexed at all — a classic, traffic-killing mistake.
- Same set in the sitemap. We emit the same
xhtml:linkalternates there too, so the signal is consistent across both channels.
6. The pitfall we refused: auto-redirect by navigator.language
Here's the one we got wrong first, fixed, and now feel strongly about. The "helpful" idea is: on first visit, read the browser's language and bounce the user to /pt/ or /es/ automatically. We shipped exactly that, reading navigator.language.slice(0,2) in our boot JS and redirecting.
It was a quiet disaster. navigator.language reflects the browser's preference list, not the user's country or intent. Visitors in Portugal with Spanish anywhere in their browser preferences were forced onto /es/ on first load. Worse, the redirect wrote the choice to localStorage, making the wrong locale sticky across sessions with no obvious escape — the homepage now opened in a language they didn't ask for, every time.
We ripped it out. The URL prefix is now the only path to a non-English locale on boot, and we bumped the storage key (amp-lang → amp-lang-v2) to invalidate the stuck values already sitting in returning visitors' browsers.
The principle: let the user choose their language explicitly and make the choice visible in the URL. A clean, shareable, bookmarkable /pt/ URL beats a clever guess every time. If you want country-level hints, surface a dismissible banner — never a silent redirect that hijacks the address bar. (Accept-Language and server-side Content-Negotiation have the exact same failure mode; we don't use them for routing either.)
7. The ordering bug that bites everyone
One last hard-won detail. In .htaccess, the i18n prefix rewrite must run before the file-exists check. This site has years of legacy WordPress URLs, some under literal /pt/ and /es/ paths from the old CMS. If Apache's "does this file/dir exist?" pass ran first, those ghost directories would shadow the rewrite and serve stale content instead of routing to the localized template. The legacy 301/410 redirects sit ahead of both, so a dead old URL is retired before any locale logic touches it. Rule ordering in .htaccess is not cosmetic — it's the control flow.
FAQ recap
Do you need a framework for a multilingual PHP site? No. Locale-from-URL, a dictionary lookup, a link helper, and correct hreflang are a few dozen lines total.
Where should the locale live? In the URL path. Not in a cookie, a session, or a browser-language guess. URLs stay shareable and search engines can index each language.
How do you avoid duplicate-content penalties? Reciprocal hreflang on every page, x-default to your default language, and self-referential canonicals per locale.
Should you auto-detect the user's language? Don't auto-redirect on it. navigator.language and Accept-Language describe preferences, not intent, and guessing wrong is sticky and infuriating. Let the URL decide.