Multilanguage on a Static Site — Configuration Over Code
symfony,php,static-site,architecturePart 10 of 10
- 19 Years of WordPress — Why I Finally Quit
- Symfony as a Static Site Generator — How holas.pl Works
- Securing a Static Site's Only Dynamic Endpoint — The Contact Form
- Building a Tool Decision Tree for Claude Code with Global Memory
- Developer Experience — From Local Dev to Production in Two Containers
- Building holas.pl with AI — Claude Code, MCP, and Local Image Generation
- 4×100 on Lighthouse Mobile — What a Static Site Actually Gets You
- SEO Engineering on a Static Site — Structured Data, Social Cards, and Crawler Signals
- Responsive Images and Scheduled Posts on a Static Site — Build-Time Solutions
- Multilanguage on a Static Site — Configuration Over Code
This is part 9 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 8 covers responsive images and scheduled posts.
Adding a second language to a Symfony site usually means duplicating controllers, hardcoding URL prefixes, and scattering locale checks everywhere. holas.pl takes a different approach: locale config lives in one YAML file, routes are generated from a custom PHP attribute, and translations are linked by the filesystem — not by explicit keys.
The Problem with Hardcoded Locales
The first working version of holas.pl's multilanguage support had around 30 places with patterns like this:
#[Route('/blog/', name: 'blog_list_en')]
public function listEn(int $page = 1): Response
{
return $this->renderList('en', $page);
}
#[Route('/pl/wpisy/', name: 'blog_list_pl')]
public function listPl(int $page = 1): Response
{
return $this->renderList('pl', $page);
}
Every route had two methods — one per locale. The real controller logic lived in a private render*() method; the public methods were pure boilerplate that set the locale and delegated. Seven controllers × two locales = 28 methods doing nothing useful.
Adding a third language would mean adding 14 more methods, plus updating templates, the locale listener, and every place that checked 'pl' === $locale. The code didn't scale.
Single Source of Truth — _site.yaml
The fix starts with centralizing the locale list. Instead of spreading locale knowledge across PHP files, everything lives in content/_site.yaml:
site:
locales:
en:
label: "English"
og_locale: en_US
date_format: "M d, Y"
pl:
label: "Polski"
og_locale: pl_PL
font_preload: fonts/inter-normal-latin-ext.woff2
date_format: "d.m.Y"
Order matters: the first key is the default locale. Each locale carries its own metadata — og_locale for Open Graph tags, date_format for template rendering, font_preload for Latin Extended characters that only Polish needs.
SiteConfigService reads this file once, caches it, and provides it to the entire application:
interface SiteConfigServiceInterface
{
/** @return string[] Ordered locale codes, first = default */
public function getLocales(): array;
public function getDefaultLocale(): string;
/** @return array<string, mixed> Config for a single locale */
public function getLocaleConfig(string $locale): array;
}
Every controller, listener, and route loader injects this interface. No PHP code imports a locale list from framework.yaml or hardcodes ['en', 'pl'].
Custom Route Attribute — #[LocalizedRoute]
The key innovation is a custom PHP attribute that replaces duplicate route methods:
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class LocalizedRoute
{
public function __construct(
public readonly string $name,
public readonly string $path,
public readonly array $requirements = [],
public readonly array $methods = [],
public readonly int $priority = 0,
) {
}
}
The attribute defines a route for the default locale only. A custom LocalizedRouteLoader scans all controllers, finds #[LocalizedRoute] attributes, and generates {name}_{locale} routes for every configured locale:
#[LocalizedRoute('blog_list', path: '/blog/')]
#[LocalizedRoute('blog_list_paginated', path: '/blog/page/{page}/', requirements: ['page' => '\d+'])]
public function list(string $locale, int $page = 1): Response
{
// one method handles all locales
}
This single method replaces the two listEn() / listPl() methods from before. The loader generates four routes from the two attributes: blog_list_en, blog_list_pl, blog_list_paginated_en, blog_list_paginated_pl.
The total across all controllers: 28 methods became 14. Every removed method was pure boilerplate.
Route Resolution
The loader needs to know that /blog/ is the English path but /pl/wpisy/ is the Polish one. A three-step resolution algorithm handles this:
private function resolvePath(
string $name, string $defaultPath,
string $locale, string $defaultLocale,
array $overrides,
): string {
if ($locale === $defaultLocale) {
return $defaultPath; // EN: /blog/
}
if (isset($overrides[$name][$locale])) {
return '/'.$locale.$overrides[$name][$locale]; // PL: /pl/wpisy/
}
return '/'.$locale.$defaultPath; // fallback: /pl/blog/
}
- Default locale — use the attribute's
pathas-is:/blog/ - Override exists — prepend
/{locale}+ the translated path from_routes.yaml - No override — prepend
/{locale}+ the default path:/pl/blog/
_routes.yaml — Translated Path Segments
Only routes with translated URL segments need overrides. Routes without entries get auto-prefixed:
routes:
blog_list:
pl: /wpisy/
blog_list_paginated:
pl: /wpisy/strona/{page}/
blog_category:
pl: /wpisy/{category}/
blog_archive:
pl: /archiwum/{year}/{month}/
contact:
pl: /kontakt/
search:
pl: /szukaj/
blog_tag has no entry, so blog_tag_pl gets the auto-prefix: /pl/tag/{tag}/. Adding German would mean adding de: entries to the routes that need translation, and nothing for routes where the English path is fine.
Two URL Resolution Patterns
Templates need to link to pages. There are two fundamentally different cases:
| Type | Pattern | Example |
|---|---|---|
| Structural (listings, search, contact, archive) | path('route_name_' ~ locale) |
path('blog_list_' ~ locale) |
| Content (pages, posts, about, privacy) | content_url(directoryKey, locale) |
content_url('about', locale) |
Structural routes come from the router — they're generated by LocalizedRouteLoader and have {name}_{locale} names. Content URLs come from frontmatter slug fields — each en.md and pl.md defines its own URL.
{# Structural: the router knows the path #}
<a href="{{ path('blog_list_' ~ locale) }}">Blog</a>
<a href="{{ path('contact_' ~ locale) }}">Contact</a>
{# Content: look up by directory key #}
<a href="{{ content_url('about', locale) }}">About</a>
<a href="{{ content_url('privacy-policy', locale) }}">Privacy</a>
content_url() is a custom Twig function that looks up a ContentItem by its directory key — the folder name — and returns the URL from its frontmatter. This replaces the old approach of hardcoding slugs per locale in templates.
Co-located Content — The Filesystem as Translation Link
Content files for the same post live in the same directory:
content/blog/tutorials/my-post/
en.md → slug: "blog/my-post"
pl.md → slug: "pl/blog/moj-wpis"
files/ → images served at /media/my-post/
Both files in the same folder are automatically linked as translations. No explicit translation_key field needed. ContentItem::directoryKey() returns the folder name ("my-post"), and TranslationMapBuilder uses it to build a lookup table:
public function build(array $trees): array
{
$map = [];
foreach ($trees as $locale => $tree) {
foreach ($tree->getAllItems() as $item) {
$key = $item->directoryKey();
if (null === $key || '' === $item->url()) {
continue;
}
$map[$key][$locale] = $item->url();
}
}
return $map;
}
The result: $map['my-post']['en'] = '/blog/my-post/', $map['my-post']['pl'] = '/pl/blog/moj-wpis/'. This map drives hreflang <link> tags in the HTML head and the language switcher.
Not every post needs both locale files. A post with only en.md won't appear in Polish listings, and the language switcher falls back to the other language's homepage.
Language Switcher — Fallback Chain
The language switcher seems simple — link to the same page in the other language. In practice, it needs to handle partial translations, tag pages, archive pages, and paginated listings. The fallback chain:
{# 1. Try translation map (page exists in other locale) #}
{% if tk and translation_map[tk][other] is defined %}
{% set url = translation_map[tk][other] %}
{% endif %}
{# 2. Try controller-provided URL (tag pages) #}
{% if url is null and lang_switch_url is defined %}
{% set url = lang_switch_url %}
{% endif %}
{# 3. Fallback: archive, paginated listing, or homepage #}
{% if url is null %}
{% if filter_type is same as('archive') and archive_year is defined %}
{% set url = path('blog_archive_' ~ other, {year: ..., month: ...}) %}
{% elseif current_page > 1 %}
{% set url = path('blog_list_paginated_' ~ other, {page: current_page}) %}
{% else %}
{% set url = path('home_' ~ other) %}
{% endif %}
{% endif %}
Tag pages get special treatment: BlogController translates the tag slug between locales (e.g., security → bezpieczenstwo) and checks whether the other locale has any posts with that tag. If yes, the switcher links to the translated tag page. If no, it falls back to the other locale's blog listing.
On the client side, locale-redirect.js handles first-visit language detection. It reads navigator.language, matches it against the configured locale list (from a data-locales attribute on <html>), and stores the preference in a cookie. On return visits, it redirects to the saved preference. The locale list isn't hardcoded in JavaScript — it comes from the same _site.yaml config, passed through Twig.
Locale Detection
LocaleListener runs at priority 8 — after Symfony's built-in locale listeners — and detects locale from the URL path:
private function detectLocale(string $path): string
{
$defaultLocale = $this->siteConfig->getDefaultLocale();
foreach ($this->siteConfig->getLocales() as $locale) {
if ($locale === $defaultLocale) {
continue;
}
if (str_starts_with($path, '/'.$locale.'/') || '/'.$locale === $path) {
return $locale;
}
}
return $defaultLocale;
}
No hardcoded /pl/ check. It iterates the configured locales dynamically. Adding a new locale to _site.yaml is enough for the listener to start detecting it.
Adding a New Language — Zero PHP Changes
This is the payoff. Adding German to holas.pl requires:
content/_site.yaml— add ade:entry with label, og_locale, date_formatcontent/_routes.yaml— addde:overrides for translated route segmentstranslations/messages.de.yaml— German UI strings (nav labels, button text, etc.)content/_tags.yaml— German tag translations- Content files — create
de.mdalongsideen.mdandpl.mdfor posts that should exist in German
No PHP files touched. No Twig templates edited. No controller methods added. The route loader generates German routes automatically. The locale listener detects /de/ paths. The language switcher renders a dropdown instead of a toggle link. The translation map includes German URLs.
The 28 locale-specific controller methods and 30 hardcoded locale checks from the first version would have meant editing 20+ files to add German. The configuration-driven approach means editing 5 config files and creating content.
The architecture decisions that make this work — SiteConfigService as the single locale authority, LocalizedRouteLoader generating routes from attributes, co-located content as the translation link — are the kind of decisions that seem like over-engineering when you only have two languages. They're not. They're the difference between "adding a language is a week of work" and "adding a language is an afternoon of config."
Next post covers how the codebase improved through iterative quality rounds — value objects, interfaces, component extraction — with AI handling the systematic review work.