Ce blog a été construit en PHP pur, sans CMS, sans base de données. Premier article de la série, déjà publié. Au bout d'un moment, la question se pose : est-ce que ça vaut le coup d'ajouter le support anglais ? La réponse courte : oui, et c'est moins compliqué qu'il n'y paraît. La réponse longue : voilà comment j'ai fait, avec les pièges qui vont avec.
Pas de gettext, pas de bibliothèque i18n, pas de base de données pour les
traductions. Juste des fichiers PHP séparés par langue, un JSON restructuré,
et trois couches de routing. Le tout tient en quelques dizaines de lignes de code
réparti sur des fichiers que vous avez déjà.
Pourquoi traduire un blog de développeur
Deux raisons concrètes, pas de la philosophie.
Le SEO anglophone est 10 à 50 fois plus grand. Une requête comme "php blog without framework" a un volume de recherche qui n'a rien à voir avec "blog php sans framework". Si vous écrivez sur des sujets techniques qui intéressent des développeurs anglophones — et c'est probablement le cas si vous lisez ceci — ne pas avoir de version EN, c'est passer à côté d'une audience existante sans effort marginal énorme une fois l'architecture en place.
Ça montre que vous êtes bilingue à des recruteurs. Un portfolio disponible en anglais, avec du contenu technique correctement rédigé, c'est un signal fort. Pas besoin d'en parler dans votre CV — le site parle pour lui. Un recruteur international qui tombe sur votre article via Google EN voit immédiatement que vous pouvez travailler en anglais.
Il y a une troisième raison qui n'est pas avouable mais réelle : écrire le même article en deux langues force à restructurer les explications. Ça améliore souvent la version française au passage.
Les choix d'architecture
Structure des URLs
La contrainte principale : ne pas casser les URLs existantes. Tous les articles
déjà publiés vivent sur /blog/slug. Ces URLs ne bougent pas.
Les versions anglaises vivent sur /en/blog/slug.
/blog/blog-multilingue-php-sans-framework # FR (défaut)
/en/blog/blog-multilingue-php-sans-framework # EN
Le préfixe /en/ est simple à détecter, à générer dans les liens,
et à router côté serveur. Pas de paramètre ?lang=en, pas de cookie,
pas de détection automatique de langue navigateur — c'est l'URL qui fait foi.
C'est le seul modèle qui fonctionne correctement pour le SEO (les moteurs de recherche
indexent des URLs distinctes) et qui respecte le principe du moindre étonnement
pour l'utilisateur.
Un fichier PHP par langue
Pour chaque article, deux fichiers :
blog/posts/
├── mon-slug.php # Version française
└── mon-slug.en.php # Version anglaise
L'alternative — un seul fichier avec des tableaux de traductions — ressemble à ça dans la vraie vie :
<?php
$texts = [
'fr' => [
'intro' => 'Ce blog a été construit en PHP pur...',
'section1' => 'Les choix d\'architecture...',
// 800 lignes de contenu entre guillemets avec des échappements partout
],
'en' => [
'intro' => 'This blog was built in pure PHP...',
// 800 autres lignes
],
];
echo $texts[$lang]['intro'];
C'est illisible, non maintenable, et vous passez la moitié du temps à gérer les apostrophes et les guillemets. Un fichier par langue est plus verbeux en termes de fichiers, mais chaque fichier est simple à lire et à modifier indépendamment. C'est le bon trade-off pour un blog.
Avantage bonus : si un article n'existe pas en anglais, le fichier
.en.php n'existe pas. Le router renvoie une 404 propre.
Pas besoin de logique de fallback complexe.
posts.json restructuré
Avant l'ajout du multilingue, le JSON était plat :
{
"slug": "mon-slug",
"date": "2026-03-15",
"title": "Titre de l'article",
"category": "Retour d'expérience",
"tags": ["php", "blog"],
"excerpt": "Résumé court."
}
Après migration, les champs dépendants de la langue sont imbriqués
dans des objets fr et en :
{
"slug": "mon-slug",
"date": "2026-03-15",
"fr": {
"title": "Titre de l'article",
"category": "Retour d'expérience",
"tags": ["php", "blog"],
"excerpt": "Résumé court."
},
"en": {
"title": "Article title",
"category": "Lessons learned",
"tags": ["php", "blog"],
"excerpt": "Short summary."
}
}
La date reste à la racine — elle est indépendante de la langue. Le slug aussi — c'est le même pour les deux versions.
La migration des données existantes se fait avec un script PHP à usage unique (voir plus bas). Ne pas le faire à la main pour 30+ articles.
template.php reçoit $lang
La signature de blog_header() avait déjà un paramètre $lang
prévu mais pas utilisé. Il devient fonctionnel :
<?php
function blog_header(
string $title,
string $description,
string $canonical_url,
?string $og_image = null,
?array $article_data = null,
string $lang = 'fr'
): void {
Ce paramètre pilote : l'attribut lang du <html>,
og:locale, les balises hreflang, le fil d'Ariane,
la navigation, et le toggle de langue. Un seul paramètre, pas de variable globale.
Les trois couches de routing
L'architecture de routing est la partie la plus délicate. Elle a trois couches qui doivent être cohérentes entre elles, sous peine d'avoir un site qui fonctionne en production mais pas en développement, ou vice versa.
Couche 1 : .htaccess pour Apache
Les URLs propres du blog FR existaient déjà dans le .htaccess.
L'ajout pour le EN se résume à trois lignes :
# Blog EN : /en/blog/slug → blog/posts/slug.en.php
RewriteRule ^en/blog/([a-z0-9-]+)/?$ blog/posts/$1.en.php [L,QSA]
# Blog listing EN : /en/blog/ → blog/index.php avec lang=en
RewriteRule ^en/blog/?$ blog/index.php?lang=en [L,QSA]
Trois lignes. C'est tout. Le reste du routing existant gère déjà les URLs FR. La règle pour les articles EN est ajoutée avant les règles FR pour éviter un conflit de pattern — les règles Apache s'appliquent dans l'ordre de déclaration.
Couche 2 : router.php pour le serveur PHP intégré
Le serveur PHP intégré (php -S localhost:8000 router.php) n'exécute
pas les .htaccess. Le router.php simule les mêmes rewrites :
<?php
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');
// Blog EN articles
if (preg_match('#^/en/blog/([a-z0-9-]+)$#', $uri, $m)) {
$file = __DIR__ . '/blog/posts/' . $m[1] . '.en.php';
if (file_exists($file)) {
require $file;
return true;
}
// Fallback 404
http_response_code(404);
require __DIR__ . '/404.php';
return true;
}
// Blog EN listing
if ($uri === '/en/blog') {
$_GET['lang'] = 'en';
require __DIR__ . '/blog/index.php';
return true;
}
La structure est identique à la logique Apache, traduite en PHP.
Le piège classique ici : oublier de mettre $_GET['lang'] = 'en'
avant d'inclure index.php. Si vous testez en local et que le listing
EN affiche des articles FR, c'est là que ça se passe.
Couche 3 : détection dans template.php
Le template peut recevoir $lang en paramètre (depuis les articles),
mais il peut aussi détecter la langue depuis l'URL (pour les pages dynamiques
qui ne passent pas par blog_header()) :
<?php
// Détection de langue depuis l'URL (fallback si $lang non fourni)
function detect_lang(): string {
$uri = $_SERVER['REQUEST_URI'] ?? '';
return str_starts_with($uri, '/en/') ? 'en' : 'fr';
}
En pratique, les articles passent toujours $lang explicitement.
La détection automatique sert principalement pour le listing et les pages
qui n'ont pas de contexte article.
Les modifications du template
html lang et og:locale
C'est la modification la plus visible et la plus simple :
<!DOCTYPE html>
<html lang="<?= $lang ?>">
<head>
...
<meta property="og:locale" content="<?= $lang === 'en' ? 'en_US' : 'fr_FR' ?>">
Balises hreflang
Les balises hreflang indiquent aux moteurs de recherche
les versions alternatives d'une page. Elles vont dans le <head>
et se génèrent depuis le slug de l'article :
<?php if (!empty($article_data['slug'])): ?>
<link rel="alternate" hreflang="fr"
href="<?= SITE_URL ?>/blog/<?= $article_data['slug'] ?>">
<link rel="alternate" hreflang="en"
href="<?= SITE_URL ?>/en/blog/<?= $article_data['slug'] ?>">
<link rel="alternate" hreflang="x-default"
href="<?= SITE_URL ?>/blog/<?= $article_data['slug'] ?>">
<?php endif; ?>
x-default pointe vers la version FR — c'est la langue par défaut
du site. Si un utilisateur arrive depuis un pays sans version localisée,
il voit le FR. Google utilise x-default pour les pages
non ciblées géographiquement.
Important : ces balises doivent être réciproques. La page FR doit pointer vers la page EN, et la page EN doit pointer vers la page FR. Si vous oubliez l'un des sens, Google ignore les deux.
Fil d'Ariane adapté
Le breadcrumb change selon la langue — pas seulement le texte, mais aussi les URLs :
<?php
$home_url = $lang === 'en' ? SITE_URL . '/en' : SITE_URL;
$blog_url = $lang === 'en' ? SITE_URL . '/en/blog' : SITE_URL . '/blog';
$home_label = $lang === 'en' ? 'Home' : 'Accueil';
$blog_label = 'Blog';
?>
<ol class="breadcrumb">
<li><a href="<?= $home_url ?>"><?= $home_label ?></a></li>
<li><a href="<?= $blog_url ?>"><?= $blog_label ?></a></li>
<li class="active"><?= htmlspecialchars($title) ?></li>
</ol>
Toggle de langue avec drapeaux SVG
Le toggle dans la navigation affiche le drapeau de la langue cible (pas la langue courante — l'autre). Ça évite le paradoxe du "FR" affiché sur une page déjà en FR.
<?php
$toggle_url = $lang === 'fr'
? SITE_URL . '/en/blog/' . ($article_data['slug'] ?? '')
: SITE_URL . '/blog/' . ($article_data['slug'] ?? '');
$toggle_lang = $lang === 'fr' ? 'EN' : 'FR';
$toggle_flag = $lang === 'fr' ? '🇬🇧' : '🇫🇷';
// Ou SVG inline si les emojis ne s'affichent pas bien partout
?>
<a href="<?= htmlspecialchars($toggle_url) ?>" class="lang-toggle"
title="<?= $lang === 'fr' ? 'Read in English' : 'Lire en français' ?>">
<?= $toggle_flag ?> <?= $toggle_lang ?>
</a>
Si vous préférez des SVG inline plutôt que des emojis (plus fiable sur les anciens OS/navigateurs), voici un exemple compact :
<?php if ($lang === 'fr'): ?>
<a href="<?= $toggle_url ?>" class="lang-toggle" title="Read in English">
<svg width="18" height="12" viewBox="0 0 60 40" xmlns="http://www.w3.org/2000/svg">
<rect width="60" height="40" fill="#012169"/>
<path d="M0,0 L60,40 M60,0 L0,40" stroke="#fff" stroke-width="8"/>
<path d="M0,0 L60,40 M60,0 L0,40" stroke="#C8102E" stroke-width="5"/>
<path d="M30,0 V40 M0,20 H60" stroke="#fff" stroke-width="12"/>
<path d="M30,0 V40 M0,20 H60" stroke="#C8102E" stroke-width="8"/>
</svg>
EN
</a>
<?php else: ?>
<a href="<?= $toggle_url ?>" class="lang-toggle" title="Lire en français">
<svg width="18" height="12" viewBox="0 0 90 60" xmlns="http://www.w3.org/2000/svg">
<rect width="30" height="60" fill="#002395"/>
<rect x="30" width="30" height="60" fill="#EDEDED"/>
<rect x="60" width="30" height="60" fill="#ED2939"/>
</svg>
FR
</a>
<?php endif; ?>
Migration de posts.json : script à usage unique
Si vous avez des articles existants avec la structure plate, voici le script de migration. À exécuter une fois, depuis la racine du projet :
<?php
// migrate-posts.php — à supprimer après usage
$json = file_get_contents('blog/posts.json');
$posts = json_decode($json, true);
$migrated = [];
foreach ($posts as $post) {
// Déjà migré ?
if (isset($post['fr'])) {
$migrated[] = $post;
continue;
}
$migrated[] = [
'slug' => $post['slug'],
'date' => $post['date'],
'fr' => [
'title' => $post['title'],
'category' => $post['category'],
'tags' => $post['tags'],
'excerpt' => $post['excerpt'],
],
'en' => [
'title' => $post['title'], // À traduire manuellement
'category' => $post['category'], // idem
'tags' => $post['tags'],
'excerpt' => $post['excerpt'], // idem
],
];
}
file_put_contents(
'blog/posts.json',
json_encode($migrated, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);
echo count($migrated) . " articles migrés.\n";
Le script copie les champs FR dans EN à titre de placeholder.
Vous retravaillez les champs EN manuellement ensuite.
Ne pas oublier de supprimer migrate-posts.php une fois terminé —
ce n'est pas un endpoint que vous voulez exposer en production.
Listing du blog (index.php)
Le listing lit posts.json en JavaScript et filtre les articles
selon la langue courante. Deux changements principaux.
Lire la langue courante
// Déterminer la langue depuis l'URL
const BLOG_LANG = window.location.pathname.startsWith('/en/') ? 'en' : 'fr';
Fetch avec chemin absolu
C'est le piège le plus courant. Si vous utilisez un chemin relatif
pour fetcher posts.json :
// ❌ Chemin relatif — PROBLÈME sur /en/blog/
fetch('posts.json')
.then(r => r.json())
Sur /blog/, ce fetch résout vers /blog/posts.json — correct.
Mais sur /en/blog/, il résout vers /en/blog/posts.json — 404.
Le fichier n'existe qu'à un seul endroit.
// ✅ Chemin absolu — fonctionne depuis n'importe quelle URL
fetch('/blog/posts.json')
.then(r => r.json())
.then(posts => renderPosts(posts, BLOG_LANG));
Filtre par langue dans le JS
function renderPosts(posts, lang) {
// Filtrer les articles qui ont une version dans cette langue
const filtered = posts.filter(post => post[lang] && post[lang].title);
filtered.forEach(post => {
const data = post[lang];
const url = lang === 'en'
? `/en/blog/${post.slug}`
: `/blog/${post.slug}`;
// Construire la card avec data.title, data.excerpt, data.category, data.tags
renderCard({ ...data, slug: post.slug, date: post.date, url });
});
}
Traduction des catégories pour les filtres
Les boutons de filtre par catégorie changent de libellé selon la langue. La correspondance FR → EN est une simple constante :
const CATEGORY_LABELS = {
fr: {
'Retour d\'expérience': 'Retour d\'expérience',
'Golang': 'Golang',
'PHP / Symfony': 'PHP / Symfony',
'DevOps / Infrastructure': 'DevOps / Infrastructure',
'Architecture': 'Architecture',
'JavaScript / Vue.js': 'JavaScript / Vue.js',
},
en: {
'Lessons learned': 'Lessons learned',
'Golang': 'Golang',
'PHP / Symfony': 'PHP / Symfony',
'DevOps / Infrastructure': 'DevOps / Infrastructure',
'Architecture': 'Architecture',
'JavaScript / Vue.js': 'JavaScript / Vue.js',
},
};
// Les catégories disponibles dans cette langue
const categories = [...new Set(
posts
.filter(p => p[BLOG_LANG])
.map(p => p[BLOG_LANG].category)
)].sort();
Les boutons sont générés dynamiquement depuis les catégories détectées,
pas depuis une liste en dur — si vous ajoutez une catégorie dans
posts.json, elle apparaît automatiquement dans le filtre.
Sitemap avec hreflang
Le sitemap XML doit déclarer les URLs alternatives pour chaque page bilingue.
Le namespace xhtml est requis pour les balises xhtml:link :
<?php
$posts = json_decode(file_get_contents(__DIR__ . '/posts.json'), true);
header('Content-Type: application/xml; charset=utf-8');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<?php foreach ($posts as $post): ?>
<?php if (!empty($post['fr'])): ?>
<url>
<loc><?= SITE_URL ?>/blog/<?= $post['slug'] ?></loc>
<lastmod><?= $post['date'] ?></lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
<xhtml:link rel="alternate" hreflang="fr"
href="<?= SITE_URL ?>/blog/<?= $post['slug'] ?>"/>
<?php if (!empty($post['en'])): ?>
<xhtml:link rel="alternate" hreflang="en"
href="<?= SITE_URL ?>/en/blog/<?= $post['slug'] ?>"/>
<xhtml:link rel="alternate" hreflang="x-default"
href="<?= SITE_URL ?>/blog/<?= $post['slug'] ?>"/>
<?php endif; ?>
</url>
<?php endif; ?>
<?php if (!empty($post['en'])): ?>
<url>
<loc><?= SITE_URL ?>/en/blog/<?= $post['slug'] ?></loc>
<lastmod><?= $post['date'] ?></lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
<xhtml:link rel="alternate" hreflang="fr"
href="<?= SITE_URL ?>/blog/<?= $post['slug'] ?>"/>
<xhtml:link rel="alternate" hreflang="en"
href="<?= SITE_URL ?>/en/blog/<?= $post['slug'] ?>"/>
<xhtml:link rel="alternate" hreflang="x-default"
href="<?= SITE_URL ?>/blog/<?= $post['slug'] ?>"/>
</url>
<?php endif; ?>
<?php endforeach; ?>
</urlset>
La priorité des articles EN (0.7) est légèrement inférieure aux articles FR (0.8) — le site est en français par défaut, les articles FR sont la source de vérité. C'est une convention, pas une règle absolue.
Articles similaires filtrés par langue
La section "articles similaires" en bas de page doit pointer vers des articles dans la même langue. Si vous lisez un article EN et que les suggestions pointent vers des URLs FR, c'est une mauvaise expérience utilisateur.
Le calcul de similarité existant (voir l'article dédié) est étendu pour filtrer par langue avant de scorer :
<?php
function get_related_posts(
string $current_slug,
array $current_tags,
string $current_category,
string $lang = 'fr',
int $limit = 3
): array {
$posts = json_decode(file_get_contents(__DIR__ . '/../posts.json'), true);
$scores = [];
foreach ($posts as $post) {
if ($post['slug'] === $current_slug) continue;
// Filtrer : l'article doit exister dans la langue cible
if (empty($post[$lang])) continue;
$data = $post[$lang];
$score = 0;
// Score par catégorie
if ($data['category'] === $current_category) $score += 3;
// Score par tags communs
$common = array_intersect($data['tags'] ?? [], $current_tags);
$score += count($common) * 2;
if ($score > 0) {
$url = $lang === 'en'
? '/en/blog/' . $post['slug']
: '/blog/' . $post['slug'];
$scores[] = [
'url' => $url,
'title' => $data['title'],
'score' => $score,
];
}
}
usort($scores, fn($a, $b) => $b['score'] - $a['score']);
return array_slice(
array_map(fn($s) => ['url' => $s['url'], 'title' => $s['title']], $scores),
0,
$limit
);
}
Appelé depuis les fichiers articles :
<?php
// Dans mon-slug.php (FR)
blog_footer(
get_related_posts('mon-slug', ['php', 'blog'], 'Retour d\'expérience', 'fr'),
'mon-slug',
'fr'
);
// Dans mon-slug.en.php (EN)
blog_footer(
get_related_posts('mon-slug', ['php', 'blog'], 'Lessons learned', 'en'),
'mon-slug',
'en'
);
Ce dont vous n'avez pas besoin
Liste des choses que j'ai envisagées et décidé de ne pas faire :
-
Bibliothèque i18n (gettext, Symfony Translation, etc.) —
Pour un blog, les textes d'interface se comptent sur les doigts d'une main :
"Lire la suite" / "Read more", "Accueil" / "Home", "min de lecture" / "min read".
Deux opérateurs ternaires dans le template, c'est suffisant.
Une bibliothèque i18n avec des fichiers
.poou.yamlserait de l'over-engineering pur. -
Détection automatique de langue —
Détecter
Accept-Languageet rediriger automatiquement, c'est une mauvaise idée. Ça casse les partages de liens, ça surprend les utilisateurs, et ça pose des problèmes d'indexation. L'URL fait foi. - Base de données pour les traductions — Si vos contenus sont des fichiers PHP, vos traductions sont des fichiers PHP. La cohérence architecturale a une valeur en soi.
-
Un seul fichier avec
switch($lang)— Vu plus haut. Illisible dès que les articles font plus de 200 lignes. - Traduction automatique (DeepL API, etc.) — Techniquement possible. Mais un article de blog technique traduit automatiquement, ça se voit. Et ça ne convainc pas un recruteur. Si l'objectif est de montrer que vous êtes bilingue, il faut vraiment écrire en anglais.
Récapitulatif des fichiers modifiés
Pour un blog déjà en production, la liste exhaustive des changements :
-
.htaccess— 2 règlesRewriteRulepour les URLs EN -
router.php— 2 blocs de routing PHP correspondants -
blog/template.php—blog_header()utilise$langpourhtml[lang],og:locale,hreflang, breadcrumb, nav, toggle ;blog_footer()reçoit$langpour filtrer les articles similaires -
blog/posts.json— migration vers structurefr/enimbriquée (script one-shot) -
blog/index.php— fetch absolu/blog/posts.json, détectionBLOG_LANGdepuis l'URL, filtre par langue, URLs adaptées -
blog/sitemap.php— ajout namespacexhtmlet balisesxhtml:link -
Pour chaque article existant :
blog/posts/slug.en.phpà créer
Aucun nouveau fichier de configuration, aucune dépendance, aucun changement de stack. L'ensemble des modifications est localisé dans des fichiers que vous maintenez déjà.
En pratique
Ce blog tourne avec cette architecture depuis la restructuration. Les URLs FR existantes n'ont pas bougé — aucune redirection à gérer, aucun impact sur le positionnement des articles déjà indexés. Les versions EN sont indexables dès leur création.
Le seul vrai coût, c'est le temps d'écriture : traduire un article de 1000 mots prend entre 30 minutes et une heure selon la densité technique. C'est ce coût-là qui est incompressible — pas l'architecture.
Si vous avez déjà un blog PHP sans framework et que vous hésitez à ajouter le support bilingue parce que vous pensez que c'est complexe : ce n'est pas le code qui est difficile.