Localization
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 examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
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.
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.
See 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.
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.
One more. This time you'll format the same price as two currencies with NumberFormatter . Fill in the locale and the formatter type.
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)
Frequently Asked Questions
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.
🎉 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
- ✅ Next lesson: Search Features — add full-text search and Elasticsearch to your app
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: PDF Generation
- Next: Search Features