Skip to content

Internationalization

Technomantus Corvi edited this page Sep 5, 2026 · 1 revision

Internationalization (i18n)

Tanuki ships with a minimal, file-based translation system: a global t() helper plus one JSON dictionary per language in lang/, and an optional URL-prefix language switcher wired in by default but inert until used.

lang/
├── en.json
└── es.json

How translations work

t('nav.home')                              // → "Home" (or "Inicio" if locale is es)
t('todo.count_summary', ['total' => 5])    // → replaces ":total" inside the string
  • The active language comes from current_locale(): a session override if one is set, otherwise APP_LOCALE from .env.
  • Keys use dot notation to reach nested sections of the JSON file (errors.404_title{"errors": {"404_title": "..."}}).
  • If a key is missing in the active locale, t() falls back to English; if it's missing there too, it returns the raw key — a missing translation is visible in the UI instead of breaking the page.
  • Dictionaries are loaded and cached once per request, so calling t() many times has no extra cost.
  • Placeholders use a leading colon (:name) and are replaced via the second argument.

Adding a new language

cp lang/en.json lang/fr.json
# translate the values in lang/fr.json

Add fr to ACCEPTED_LANGUAGES (see below) if you want it selectable via URL, and give it a flag/label in locale_meta() (utils.php) if you want it in the switcher.

Formatting dates

format_date($datetime) // 'en' → m/d/Y H:i, 'es' → d/m/Y H:i

The language switcher

Unlike the base i18n system, the switcher is wired in by default but stays entirely inert until a /xx/ URL is visited or a flag is clicked — no overhead for a project that never uses it.

# .env
APP_LOCALE=en
ACCEPTED_LANGUAGES=en,es
  • Visiting a URL prefixed with a 2-letter code that has a matching dictionary (/es/todo) sets that language for the session and strips the prefix before normal routing continues — no need to duplicate routes with a language segment.
  • ACCEPTED_LANGUAGES controls which prefixes are enabled. A prefix whose dictionary exists but isn't listed there returns a 404 — deliberate: you can ship a dictionary you're still working on without exposing it.
  • Once chosen (URL prefix or flag), the language sticks in the session — other links don't need a prefix.
  • If ACCEPTED_LANGUAGES isn't set at all, the switcher doesn't render and /xx/ prefixes aren't treated as locale prefixes — the project is single-language, using only APP_LOCALE.
  • Flags aren't hardcoded — includes/head.php loops over accepted_locales(), so removing a language from .env removes its flag automatically. Adding a new language still needs one line in locale_meta() (utils.php) for its flag emoji and label.

Known limitation: a route's first path segment can't share a name with an enabled language code that also has a matching dictionary file (e.g. avoid a route literally named /es) — the router treats any 2-letter segment with a matching lang/xx.json as a locale prefix before normal route matching.

<html lang="...">

includes/head.php sets this from current_locale() automatically.

Clone this wiki locally