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.