No dictionary file, no translation keys to keep in sync — every string carries both languages right where it renders.
Most guides to multilingual PHP sites lead with a centralized dictionary: one array, one lookup function, every page pulling strings from a single source of truth. It's a solid pattern, and it's the right one for a site with real text reuse across many pages. It is not, however, the only pattern worth knowing — and for a small site where most content is genuinely page-specific, a different approach turns out to be simpler in practice.
This is that approach: every translatable string lives directly on its own HTML element, in both languages at once, as a pair of data- attributes. No separate file to keep synchronized with the templates that reference it. What's in the page file is everything that exists — full stop.
The core pattern
An element renders one language by default — decided server-side — and carries the other language as a data attribute, ready to swap in without another server round-trip.
<h1 data-it="Sviluppo Web" data-en="Web Development">Sviluppo Web</h1>
<p data-it="Testo descrittivo in italiano."
data-en="Descriptive text in English.">
Testo descrittivo in italiano.
</p>
Content and translation sit together, in context, exactly where they render. There's no risk of a dictionary key that exists in PHP but is never actually called, or a template referencing a key nobody added — the failure mode that quietly creeps into larger centralized-dictionary sites over time.
What this trades away, honestly
A tagline or button label repeated across ten pages has to be written out ten times, not referenced once from a shared source. On a five-to-ten-page site, that cost is trivial. On a much larger site with heavy text reuse, a centralized dictionary starts to earn its overhead back — which is why this isn't presented as a universal replacement for that pattern, just a better fit for a specific size of project.
Server-side language selection still matters
PHP decides which language renders by default, read from the querystring and validated rather than trusted directly.
$lang = (isset($_GET['lang']) && $_GET['lang'] === 'en') ? 'en' : 'it';
<html lang="<?php echo $lang; ?>">
This isn't just about picking a default — it's what lets search engines see server-rendered content in the requested language without depending on JavaScript executing correctly for indexing, and it sets the <html lang> attribute correctly from the first byte of the response, which accessibility tools rely on directly.
The JavaScript swap layer
One shared script walks every element carrying a bilingual attribute pair and swaps its content when the active language changes client-side.
window.LANG = "it"; // set server-side, read here
function applyLanguage(lang) {
document.querySelectorAll('[data-it][data-en]').forEach(function (el) {
var text = el.getAttribute('data-' + lang);
if (text !== null) {
el.innerHTML = text;
}
});
document.documentElement.setAttribute('lang', lang);
}
Two details worth being deliberate about. window.LANG is printed server-side into an inline script tag, sourced from the same $lang variable that decided the initial render — server and client agree on the active language rather than the script guessing independently. And the swap targets innerHTML, not textContent. Some strings legitimately contain inline formatting — a line break, an emphasis tag inside a headline — and textContent would silently strip it.
Keeping language state across navigation
Without extra handling, every internal link resets the visitor back to the default language on click — a frustrating, common bug in hand-rolled multilingual sites. A marker attribute plus a small script pass fixes it:
<a href="/about.php?lang=it" data-keep-lang data-it="Chi Siamo" data-en="About Us">Chi Siamo</a>
document.querySelectorAll('[data-keep-lang]').forEach(function (link) {
var url = new URL(link.href, window.location.origin);
url.searchParams.set('lang', window.LANG);
link.href = url.toString();
});
Runs once on page load, rewrites every marked link's ?lang= to match the currently active language. A visitor browsing in English stays in English through the whole site, without re-selecting it on every page.
The language toggle is a real navigation, on purpose
<button onclick="window.location.href=window.location.pathname+'?lang=it'">IT</button>
<button onclick="window.location.href=window.location.pathname+'?lang=en'">EN</button>
Not a client-side-only swap. A full page load, so the server-rendered version and every meta tag stay correct for whichever language the visitor lands on — cutting that corner would leave the visible content translated while the page's actual <title> and description silently didn't follow.
Meta tags need their own handling — this is the part that gets skipped
The data-it/data-en pattern only applies to visible content. Meta tags aren't visible, so they need to be set server-side in PHP before the page starts rendering at all.
$meta_title = ($lang === 'en') ? "Company — Full-Stack Web Development" : "Azienda — Sviluppo Web Full-Stack";
$meta_description = ($lang === 'en') ? "English description here." : "Descrizione italiana qui.";
<title><?php echo $meta_title; ?></title>
<meta name="description" content="<?php echo $meta_description; ?>">
<meta property="og:locale" content="<?php echo ($lang === 'en') ? 'en_US' : 'it_IT'; ?>">
Add hreflang alternates so search engines read the two versions as translations of one page, not duplicate content:
<link rel="alternate" hreflang="it" href="https://example.com/page.php?lang=it">
<link rel="alternate" hreflang="en" href="https://example.com/page.php?lang=en">
<link rel="alternate" hreflang="x-default" href="https://example.com/page.php">
I'd flag this as the single most common gap on a site built quickly with this pattern — visible content gets fully bilingual treatment, and the meta tags quietly stay in whichever language got written first, because they're easy to forget precisely because they're invisible on the page itself.
Header and footer stay shared, regardless
Translation living inline doesn't change the case for sharing structural markup. A navigation change or footer address update touching every page file individually is exactly the maintenance problem shared includes exist to avoid.
<?php include 'header.php'; ?>
<!-- page-specific content -->
<?php include 'footer.php'; ?>
header.php and footer.php carry their own bilingual attributes exactly like any other content — included once per page, working identically to everywhere else in the site.
Getting a working copy of this
The full specification — including the complete intake template for your own pages, images, and copy — is available as a blueprint document, meant to be handed to an AI coding assistant along with your site's actual content.
How to use the blueprint, step by step
1. Download the file. A plain .md text file, nothing to install.
2. Open a new conversation with an AI coding assistant. Google AI Studio, Claude Projects, Qwen Chat, or any comparable tool that reads attached files and generates PHP/HTML/JS.
3. Attach the file, then fill in and provide the intake section — site name, languages, page list, navigation labels, footer content, and any images or logo you want included. The more specific this is, the closer the first output gets to something deployable.
4. Review before deploying. Check that meta tags and hreflang tags actually vary by page and language rather than being copied once, and treat the first generation as a structural skeleton — visual polish is a normal second pass, not something to expect on the first attempt.





