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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
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 {" "} 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.
📋 Quick Reference — Localization
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.
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.