Localization & Internationalization
Reviewed & published by Brayan K
By the end of this lesson you'll build multilingual PHP apps: a translation system with placeholders, correct plurals for every language, locale-aware money and dates with the intl extension, and automatic language detection — without hardcoding a single string.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Explain the difference between i18n (internationalization) and l10n (localization)
- Build a translation lookup with :placeholder interpolation and a fallback locale
- Format currency and dates per locale with NumberFormatter and IntlDateFormatter
- Pick the correct plural form in any language using MessageFormatter
- Detect the visitor's locale from URL, cookie, and the Accept-Language header
- Keep accents intact with UTF-8 and the mb_ string functions
1️⃣ Translation Systems: Never Hardcode Text
The single most important habit in i18n is to separate text from code. Instead of writing echo "Welcome!", you store every user-facing string in a message file under a key like welcome, then look it up: t('welcome'). A placeholder such as :name is a slot you fill at runtime, so "Hello, :name!" becomes "¡Hola, Alice!" in Spanish. The ?? operator below is the null-coalescing operator — "use the left value, or the right one if it's missing" — which gives you a clean fallback chain.
<?php
// i18n vs l10n — two words you'll hear constantly:
// i18n (internationalization) = making your code CAPABLE of many languages
// (no hardcoded text, placeholders, plural slots)
// l10n (localization) = the actual translations + locale formatting
//
// The golden rule: NEVER hardcode user-facing text. Look it up by a KEY instead.
class Translator
{
/** @var array<string, array<string,string>> locale => (key => text) */
private array $messages = [];
public function __construct(
private string $locale, // the language we're showing now
private string $fallback = 'en', // used when a key is missing
) {}
// Load one language's message file (here just an array).
public function load(string $locale, array $messages): void
{
$this->messages[$locale] = $messages;
}
public function setLocale(string $locale): void
{
$this->locale = $locale;
}
// Look up $key for the current locale; fall back to English, then the key.
// Replace :placeholders (e.g. :name) with the values you pass in.
public function t(string $key, array $params = []): string
{
$msg = $this->messages[$this->locale][$key]
?? $this->messages[$this->fallback][$key] // ?? = "use this if null"
?? $key; // last resort: show the key
foreach ($params as $name => $value) {
$msg = str_replace(":{$name}", (string) $value, $msg);
}
return $msg;
}
}
$trans = new Translator('en');
$trans->load('en', [
'welcome' => 'Welcome to our store!',
'greeting' => 'Hello, :name!',
'checkout' => 'Proceed to Checkout',
]);
$trans->load('es', [
'welcome' => '¡Bienvenido a nuestra tienda!',
'greeting' => '¡Hola, :name!',
'checkout' => 'Proceder al pago',
]);
foreach (['en', 'es'] as $locale) {
$trans->setLocale($locale);
echo "[{$locale}]\n";
echo " " . $trans->t('welcome') . "\n"; // no placeholder
echo " " . $trans->t('greeting', ['name' => 'Alice']) . "\n"; // :name -> Alice
echo " " . $trans->t('checkout') . "\n";
}Notice that the code never changed between locales — only the data did. That is i18n (the structure) and l10n (the Spanish array) working together. Real apps load these arrays from per-language files (en.php, es.php, or JSON), which is exactly how Laravel and Symfony work under the hood.
2️⃣ setlocale, gettext & the UTF-8 Rule
PHP's classic localization tools are setlocale() and gettext. setlocale(LC_MONETARY, 'de_DE.UTF-8') changes how some built-in functions behave, but it depends on locales being installed on the server, so it's fragile — prefer the intl extension in the next section. gettext is the GNU translation standard: you wrap text in _('Welcome') and ship compiled .mo files per language. Whichever you choose, the non-negotiable rule is UTF-8 everywhere — and to count or cut non-ASCII text you must use the multibyte mb_ functions, because the plain ones work in bytes, not characters.
<?php
// setlocale() changes how PHP's BUILT-IN functions behave for a category:
// LC_MONETARY -> money, LC_TIME -> dates, LC_ALL -> everything.
// It depends on locales being installed on the server, so prefer intl
// (next section) for real formatting. This shows the classic approach.
// gettext is the GNU translation standard. You wrap text in _() and ship
// compiled .mo files per language. The pattern (not run here) looks like:
//
// setlocale(LC_MESSAGES, 'es_ES.UTF-8');
// bindtextdomain('messages', __DIR__ . '/locale');
// textdomain('messages');
// echo _('Welcome'); // -> "Bienvenido" if es_ES is installed
// echo ngettext('1 file', '%d files', $n); // gettext picks the plural form
// Whichever system you use, ALWAYS declare UTF-8 so accents survive:
header('Content-Type: text/html; charset=UTF-8'); // for web pages
mb_internal_encoding('UTF-8'); // multibyte string ops
$word = 'Crème brûlée';
echo $word . "\n";
echo "strlen (bytes): " . strlen($word) . "\n"; // counts BYTES
echo "mb_strlen (chars): " . mb_strlen($word) . "\n"; // counts CHARACTERSSee the difference? strlen reports 13 because the accented letters take two bytes each in UTF-8, while mb_strlen correctly reports 12 characters. Get this wrong and you'll slice a character in half and produce mojibake — those garbled é symbols.
3️⃣ The intl Extension: Currency, Dates & Locale Detection
The same number is written completely differently around the world: 1,234,567.89 in the US is 1.234.567,89 in Germany, and Japanese yen has no decimal places at all. PHP's intl extension wraps ICU — the industry-standard Unicode library — so NumberFormatter and IntlDateFormatter apply each locale's real rules for you, including the correct currency symbol and its position. Dates also need a time zone: always store times in UTC, then convert to the visitor's zone when you display them.
The detectLocale() function shows the priority order professionals use: an explicit choice (?lang=fr or a /fr/ URL prefix) beats a saved cookie, which beats the browser's Accept-Language header, which beats a default. Locale::acceptFromHttp() parses that messy header (fr-CH,fr;q=0.9,en;q=0.8) and returns the best match.
4️⃣ Plurals Done Right with MessageFormatter
"5 items" feels trivial in English, but plural rules vary enormously: English has 2 forms, Polish has 3 (for 1, for 2–4, and for 5+), and Arabic has 6. If you write $n === 1 ? 'item' : 'items' you've baked English grammar into your code and it will be wrong everywhere else. MessageFormatter uses ICU's {count, plural, ...} syntax, reads the locale's plural rules, and picks the right form — the # is replaced by the number, formatted for that locale.
<?php
// "5 items" is easy in English, but plural RULES differ wildly:
// English: 1 = singular, everything else = plural
// Polish: different forms for 1, 2–4, and 5+
// Arabic: SIX forms (zero, one, two, few, many, other)
// Never build plurals with if/else — use ICU's MessageFormatter {plural}.
$pattern = [
'en' => '{count, plural, =0 {No items} one {# item} other {# items}}',
'pl' => '{count, plural, =0 {Brak} one {# produkt} few {# produkty} many {# produktów} other {# produktu}}',
];
foreach (['en', 'pl'] as $locale) {
echo "[{$locale}]\n";
foreach ([0, 1, 3, 5] as $count) {
// ICU reads the locale's plural rules and picks the right form.
// '#' is replaced by the number, formatted for the locale.
$text = MessageFormatter::formatMessage($locale, $pattern[$locale], ['count' => $count]);
echo " count={$count}: {$text}\n";
}
}5️⃣ Your Turn: Translate & Format
Now you drive. The first script is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish this translator so French works too.
// Fill in each blank marked ___ , then run it and check the Output panel.
$messages = [
'en' => ['hello' => 'Hello, :name!'],
'fr' => ['hello' => ___], // 👉 the French greeting, e.g. 'Bonjour, :name !'
];
function t(array $messages, string $locale, string $key, array $params = []): string {
$msg = $messages[$locale][$key] ?? $messages['en'][$key] ?? $key;
foreach ($params as $name => $value) {
// 👉 replace :name etc. with the value. Fill in the search needle:
$msg = str_replace(___, (string) $value, $msg); // 👉 e.g. ":{$name}"
}
return $msg;
}
echo t($messages, 'en', 'hello', ['name' => 'Sam']) . "\n";
echo t($messages, 'fr', 'hello', ['name' => 'Sam']) . "\n";
// ✅ Expected output:
// Hello, Sam!
// Bonjour, Sam !One more. This time you'll format the same price as two currencies with NumberFormatter. Fill in the locale and the formatter type.
<?php
// 🎯 YOUR TURN — show the same price in two currencies using intl.
// Fill in the ___ blanks, then run it. (Needs the intl extension.)
$price = 49.5;
// 1) A US-dollar formatter for the en-US locale:
$usd = new NumberFormatter('en-US', NumberFormatter::___); // 👉 CURRENCY
echo $usd->formatCurrency($price, 'USD') . "\n";
// 2) A euro formatter for the de-DE locale:
$eur = new NumberFormatter(___, NumberFormatter::CURRENCY); // 👉 'de-DE'
echo $eur->formatCurrency($price, 'EUR') . "\n";
// ✅ Expected output (symbols/spacing may vary by ICU version):
// $49.50
// 49,50 €Common Errors (and the fix)
- Hardcoded or concatenated strings — echo "You have " . $count . " items" can't be translated and breaks where word order differs. Move the whole sentence into a message file with a placeholder: t('cart.items', ['count' => $count]).
- Wrong plural rules — $n === 1 ? 'item' : 'items' bakes in English grammar and fails in Polish, Arabic, Russian, etc. Use MessageFormatter with a {count, plural, ...} pattern so ICU picks the right form.
- Accents become é or ? (mojibake) — an encoding mismatch. Save files as UTF-8, send charset=UTF-8, call mb_internal_encoding('UTF-8'), and use utf8mb4 in MySQL. Count/cut text with mb_strlen / mb_substr, never strlen / substr.
- "Class 'NumberFormatter' not found" — the intl extension isn't enabled. Enable extension=intl in php.ini (or apt install php-intl); check with php -m | grep intl.
- Currency formatting with number_format() — it forces you to hardcode the symbol, separators, and decimal count, which you'll get wrong for locales you don't speak (and yen has no decimals). Use $fmt->formatCurrency($amount, 'EUR') instead.
Pro Tips
- 💡 Use URL prefixes (/fr/products) over ?lang=fr — search engines index each language as its own page, and you can add hreflang tags so Google serves the right one.
- 💡 Store all times in UTC and convert to the visitor's zone only when displaying. Mixing zones in storage is a classic source of "off by a day" bugs.
- 💡 Plan for RTL. Arabic, Hebrew, Persian, and Urdu read right-to-left. Set dir="rtl" and lang="ar" on <html>, use CSS logical properties (margin-inline-start, not margin-left), and let the browser mirror the layout.
- 💡 Always give translators context. A key like button.save beats save — the same English word can need different translations as a noun vs a verb.
📋 Quick Reference — Localization
| Tool | Example | What It Does |
|---|---|---|
| NumberFormatter | $f->formatCurrency($n,'EUR') | Locale-aware money / numbers / percent |
| IntlDateFormatter | $d->format($date) | Locale & timezone-aware dates |
| MessageFormatter | ::formatMessage($loc,$p,$args) | Correct plurals per language |
| Locale::acceptFromHttp | (...$_SERVER['HTTP_ACCEPT_LANGUAGE']) | Best locale from the browser header |
| setlocale / gettext | _('Welcome') | Classic GNU translation system |
| mb_strlen / mb_substr | mb_strlen($s) | Count / cut by character, not byte (UTF-8) |
Mini-Challenge: Order Summary
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This combines a plural phrase and a currency format — exactly the write-run-check loop you'll use on real localized features.
<?php
// 🎯 MINI-CHALLENGE: a locale-aware "order summary" line.
// No code is filled in — work from the steps, then run it.
//
// 1. Set $locale = 'de-DE' and $count = 3 and $total = 1499.0 .
// 2. Build a plural-correct item phrase with MessageFormatter, e.g.
// '{count, plural, one {# Artikel} other {# Artikel}}'
// 3. Format $total as EUR currency with NumberFormatter(... CURRENCY).
// 4. echo one line: "<itemPhrase> — <formattedTotal>"
//
// Tip: MessageFormatter::formatMessage($locale, $pattern, ['count' => $count])
// Tip: don't forget mb_internal_encoding('UTF-8') if you add accents.
//
// ✅ Expected output (de-DE, spacing may vary):
// 3 Artikel — 1.499,00 €
// your code here🎉 Lesson Complete!
- ✅ i18n makes code language-capable; l10n supplies each language's translations and formats
- ✅ Never hardcode text — look it up by key with :placeholder slots and a fallback locale
- ✅ Use the intl extension: NumberFormatter for money, IntlDateFormatter for dates (always store UTC)
- ✅ MessageFormatter picks the correct plural form for any language
- ✅ Detect locale in priority order (URL → cookie → Accept-Language → default), and plan for RTL
- ✅ Keep everything UTF-8 and reach for the mb_ string functions
Practice quiz
What is the difference between i18n and l10n?
- i18n is the translations; l10n is the code structure
- They are two names for the same thing
- i18n (internationalization) makes your code capable of any language; l10n (localization) is the actual translations and locale formatting
- i18n is for dates only; l10n is for text only
Answer: i18n (internationalization) makes your code capable of any language; l10n (localization) is the actual translations and locale formatting. You do i18n once in the code (no hardcoded text, placeholders), then l10n many times — one per market.
What is the golden rule of internationalization taught in this lesson?
- Never hardcode user-facing text — look it up by a key instead
- Always translate text at runtime with an API
- Use only English for error messages
- Store all text in the database
Answer: Never hardcode user-facing text — look it up by a key instead. Separate text from code: store every string under a key and look it up, e.g. t('welcome').
In the translator, what does the ?? (null-coalescing) operator provide?
- A way to concatenate strings
- Automatic translation to Spanish
- A loop over all locales
- A fallback chain — use the current locale's text, or the fallback locale, or finally the key itself
Answer: A fallback chain — use the current locale's text, or the fallback locale, or finally the key itself. ?? gives a clean fallback: current locale → fallback locale → the key as a last resort.
Why must you use mb_strlen() instead of strlen() for accented (non-ASCII) text?
- strlen() is deprecated in PHP 8
- strlen() counts bytes, while mb_strlen() counts characters — and accented letters take more than one byte in UTF-8
- mb_strlen() is faster
- strlen() only works on numbers
Answer: strlen() counts bytes, while mb_strlen() counts characters — and accented letters take more than one byte in UTF-8. Plain string functions work in bytes; mb_ functions work in characters, so a multibyte character isn't sliced in half.
Which extension wraps ICU to format currency, dates, and plurals per locale?
- intl
- mbstring
- gettext
- iconv
Answer: intl. The intl extension wraps ICU — the same Unicode library Chrome and Java use — for NumberFormatter, IntlDateFormatter, MessageFormatter.
Why prefer NumberFormatter over number_format() for currency?
- number_format() can't handle decimals
- number_format() is removed in PHP 8.4
- NumberFormatter already knows each locale's real rules (symbol, separators, decimal count), which number_format() forces you to hardcode
- NumberFormatter is shorter to type
Answer: NumberFormatter already knows each locale's real rules (symbol, separators, decimal count), which number_format() forces you to hardcode. number_format() makes you hardcode separators and symbols you'll get wrong; intl applies the locale's actual rules, e.g. yen has no decimals.
Why should you never write plurals as $n === 1 ? 'item' : 'items'?
- It is slower than a loop
- It bakes in English grammar — Polish has 3 plural forms, Arabic has 6 — so it's wrong in other languages
- PHP can't compare integers that way
- It only works for numbers under 100
Answer: It bakes in English grammar — Polish has 3 plural forms, Arabic has 6 — so it's wrong in other languages. Plural rules vary by language; use MessageFormatter with a {count, plural, ...} pattern so ICU picks the right form.
Which class picks the correct plural form for a given locale using ICU's {count, plural, ...} syntax?
- NumberFormatter
- IntlDateFormatter
- Locale
- MessageFormatter
Answer: MessageFormatter. MessageFormatter reads the locale's plural rules and selects the right form, replacing # with the formatted number.
What is the recommended priority order for detecting a visitor's locale?
- Accept-Language header first, then everything else
- Explicit choice (?lang= or /fr/) → saved cookie → Accept-Language header → default
- Default locale always wins
- Random selection from supported locales
Answer: Explicit choice (?lang= or /fr/) → saved cookie → Accept-Language header → default. An explicit user choice beats a cookie, which beats the browser's Accept-Language header, which beats a default.
When displaying dates across time zones, what does the lesson recommend?
- Store times in the visitor's local zone
- Ignore time zones entirely
- Always store times in UTC, then convert to the visitor's zone only when displaying
- Store a separate copy per time zone
Answer: Always store times in UTC, then convert to the visitor's zone only when displaying. Store all times in UTC and convert on display; mixing zones in storage causes off-by-a-day bugs.
Continue this course
- Previous: Working with PDF Generation & File Exporting
- Next: Building Search Features (Full-Text Search, Elastic, Meilisearch) — Add full-text search to PHP apps using MySQL FTS, Elasticsearch, or Meilisearch
- Quick reference: PHP cheat sheet
Frequently asked questions
What is the difference between i18n and l10n?
Internationalization (i18n — 18 letters between the i and the n) is the engineering work that makes your app capable of any language: no hardcoded text, placeholders for names and counts, and slots for plural forms. Localization (l10n) is the per-language work that fills those slots — the actual translations plus locale-aware formatting of dates, numbers, and currency. You do i18n once in the code, then l10n many times, once per market.
Should I use gettext or array-based message files?
Both are valid. gettext is the long-standing GNU standard: you wrap text in _(), and translators edit .po files that compile to fast binary .mo files, with built-in plural support via ngettext. Array files (or JSON/YAML loaded into a Translator class) are simpler to set up, easy to version in git, and what most modern frameworks like Laravel and Symfony use under the hood. For a new project, array/JSON files with an intl-based plural layer are usually the least friction; reach for gettext when you already have a translator workflow built around .po files.
Why use the intl extension instead of number_format() and date()?
Because intl wraps ICU, the industry-standard Unicode library, so it already knows every locale's real rules. number_format() and date() force you to hardcode the decimal separator, thousands separator, currency symbol, and month names — and you will get them wrong for locales you don't speak. NumberFormatter, IntlDateFormatter, and MessageFormatter handle currency placement, Japanese yen having no decimals, German using a comma for decimals, and correct plural forms automatically. Always prefer intl for user-facing output.
How do I handle plurals correctly across languages?
Never build them with if (count === 1). English has 2 plural forms, Polish has 3, and Arabic has 6 (zero, one, two, few, many, other). Use ICU's MessageFormatter with a {count, plural, ...} pattern, or gettext's ngettext(). ICU reads the locale's plural rules and selects the right form for you, so the same code prints '5 items' in English and '5 produktów' in Polish without any branching in your PHP.
Why do accented characters turn into question marks or mojibake?
Almost always an encoding mismatch. Make everything UTF-8 end to end: save your PHP files as UTF-8, send header('Content-Type: text/html; charset=UTF-8'), set mb_internal_encoding('UTF-8'), and configure your database connection to utf8mb4. Also use the mb_ string functions (mb_strlen, mb_substr, mb_strtoupper) for non-ASCII text, because the plain versions count and cut bytes, not characters, and will slice a multi-byte character in half.
How should I detect which language to show a visitor?
Check sources in priority order: an explicit ?lang= choice or /fr/ URL prefix, then a saved cookie or session value, then the browser's Accept-Language header via Locale::acceptFromHttp(), and finally a default locale. For SEO, prefer a URL segment like /fr/products over a query parameter so search engines index each language as its own page. Whatever the user actively chooses should win and be remembered in a cookie.