
setlocale() and Its Six Personalities
What does setlocale() actually do? It changes the date format, right? It’s a fair guess, and it’s wrong in an interesting way: setlocale() doesn’t touch one behavior, it touches six, and most of them have nothing to do with dates. Under the hood it’s a thin wrapper around the C library’s setlocale(3), which means PHP inherited not just the feature but the entire category structure glibc invented for it in the 1990s. It is a global state that happily outlives the request that set it, if your SAPI happens to keep the process around.
Six LC_* categories, one call. LC_ALL sets them all at once, which is convenient right up until the moment it silently reaches into a category you forgot existed.
Six categories, one entry point
| Constant | Controls | Key functions |
|---|---|---|
LC_COLLATE |
String comparison order | strcoll(), usort() |
LC_CTYPE |
Character classification, case conversion | strtoupper(), strtolower(), ctype_* |
LC_MONETARY |
Currency formatting | localeconv(), NumberFormatter |
LC_NUMERIC |
Decimal point, digit grouping | localeconv() |
LC_TIME |
Date and time formatting | strftime() (deprecated) |
LC_MESSAGES |
Translated system responses | gettext(), _() |
LC_ALL sets every row in that table with one call. That’s the whole design tension of setlocale() in one sentence: it’s grouped by what glibc happened to ship together in 1993, not by what a PHP developer would actually want to change together. Wanting French date formatting has nothing to do with wanting French decimal separators, but LC_ALL will hand you both whether you asked or not.
Note that is the source of a classic bugs, where calling setlocale() with LC_ALL has far reaching impacts that break the rest of the application. Nothing like changing the date format to Spanish, and see the sorting of customer names go awry. Connecting theses dots is quite a challenge.
LC_COLLATE: string comparison order
strcoll() orders strings the way a human from that culture would alphabetize them, not the way their byte values happen to sort. In English that’s mostly invisible. In Swedish, ö sorts after z. In German, ä behaves like ae for collation purposes. Your “alphabetical” sort was only ever alphabetical for one alphabet.
<?php $cities = ['Örebro', 'Stockholm', 'Uppsala', 'Åre']; setlocale(LC_COLLATE, 'en_US.UTF-8'); usort($cities, 'strcoll'); // ['Stockholm', 'Uppsala', 'Åre', 'Örebro'] setlocale(LC_COLLATE, 'sv_SE.UTF-8'); usort($cities, 'strcoll'); // ['Åre', 'Örebro', 'Stockholm', 'Uppsala'] ?>
Same array, same usort() call, same strcoll callback, two completely different orderings, because the only thing that changed is a global the sort function silently consults. If a user ever files a bug titled “sorting is wrong” and can’t say how, check the locale before you check the algorithm.
| Rank | C (byte order) | de_DE | sv_SE |
|---|---|---|---|
| 1 | a | a | a |
| 2 | ä | o | o |
| 3 | o | z | z |
| 4 | ö | ä (sorts as “ae”) | ö (after z) |
| 5 | z | ö (sorts as “oe”) | ä (after ö) |
LC_CTYPE — character classification and conversion
strtoupper() and strtolower() don’t have a fixed idea of what “uppercase” means — they ask the current LC_CTYPElocale. Under the plain C locale, anything outside ASCII is invisible to them; accented letters pass through untouched. Give PHP an actual language locale, and accented characters finally get treated as letters instead of noise.
<?php $text = 'café über naïve'; setlocale(LC_CTYPE, 'C'); echo strtoupper($text); // CAFé üBER NäIVE setlocale(LC_CTYPE, 'fr_FR.UTF-8'); echo strtoupper($text); // CAFÉ UBER NAÏVE ?>
| Input | C locale | fr_FR locale |
|---|---|---|
é |
é (unchanged) |
É |
ü |
ü (unchanged) |
Ü |
ï |
ï (unchanged) |
Ï |
c |
C |
C |
And then there’s Turkish, which every locale-aware string function eventually has to apologize for. Under tr_TR, uppercasing "i"produces "İ" (dotted capital I), not "I", and lowercasing "I" produces "ı" (dotless), not "i". This is entirely correct Turkish orthography and entirely fatal to any code that assumed strtolower($username) === strtolower($input) was a safe way to compare identifiers. Somewhere there is a login form that only breaks for users named İbrahim, and nobody on the team can reproduce it from their desk in Amsterdam.
LC_MONETARY: currency formatting is out
This is the section for the older code bases. money_format(), the function every LC_MONETARY tutorial reaches for, was deprecated in PHP 7.4 and removed outright in PHP 8.0. If you’re running anything newer, the function simply doesn’t exist. You can skip this section.
If you stay with me, I have to let you know setlocale(LC_MONETARY, ...) still works, but nothing in the engine uses that configuration anymore. In particular, number_format() is not affected, because it doesn’t have any currency symbol.
We can recommend using the brick/money component, or read PHP Number Format Currency – How to Format Currencies in PHP.
The most accessible replacement is NumberFormatter and it isn’t locale-dependent at all. It works with the intl extension, which takes a locale as an explicit argument instead of an ambient global.
<?php
$amount = 1234567.89;
$fr = new NumberFormatter('fr_FR', NumberFormatter::CURRENCY);
echo $fr->formatCurrency($amount, 'EUR');
// 1 234 567,89 €
$de = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $de->formatCurrency($amount, 'EUR');
// 1.234.567,89 €
?>
| Locale | Formatted output |
|---|---|
| en_US | $1,234,567.89 |
| de_DE | 1.234.567,89 € |
| fr_FR | 1 234 567,89 € |
| ja_JP | ¥1,234,568 |
| hi_IN | ₹12,34,567.89 (Indian digit grouping) |
Notice what changed structurally, not just cosmetically: NumberFormatter takes the locale as a constructor argument. setlocale() doesn’t have an argument for “which call this applies to”: it applies to the whole process until you call it again. That difference is the entire second half of this article.
LC_NUMERIC: the one that bites the old
LC_NUMERIC swaps the decimal point and the digit-grouping separator: 1,234.56 in the US becomes 1.234,56 in Germany. Reasonable enough for display.
The localisation used to happen whenever a number had to be cast to a string, until PHP 8.0. Before that, the conversion string to integer did not agree with the conversion integer to string.
<?php echo (float) (string) 3.14; // 3.14 setlocale(LC_ALL, 'de_DE'); echo (float) (string) 3.14; // 3 ?>
Since PHP 8.0, all conversion are locale independent, so the above doesn’t happen anymore. In fact, the only leftover of that era is the %F format of the *printf() family.
<?php
setlocale(LC_ALL, 'de_DE');
printf("%.2f", 3.14); // 3,14
print PHP_EOL;
printf("%.2F", 3.14); // 3.14
?>
So, the trap is that PHP’s own number parsing doesn’t get the memo: floatval(), (float) casts, and JSON encoding/decoding all assume a period, always, regardless of locale.
<?php
setlocale(LC_NUMERIC, 'de_DE.UTF-8');
$lc = localeconv();
echo $lc['decimal_point']; // ","
echo $lc['thousands_sep']; // "."
// This now quietly breaks:
$val = floatval("3,14"); // 3.0 — not 3.14
?>
floatval() stops reading at the first non-numeric character. It doesn’t consult LC_NUMERIC to figure out what “the decimal point” means this week; it only ever knows .. So the moment you set a European locale, "3,14" doesn’t parse as 3.14 — it parses as 3, followed by a comma it discards. No warning, no exception, just a number that’s wrong by a factor that depends on where the comma was.
This is the trap worth memorizing about LC_ALL: calling it with any European locale drags LC_NUMERIC along for the ride even when all you wanted was French month names. If you must set LC_ALL, immediately pin LC_NUMERIC back to C:
<?php setlocale(LC_ALL, 'fr_FR.UTF-8'); setlocale(LC_NUMERIC, 'C'); // undo the part that breaks floatval() ?>
And as a side note, please, upgrade to PHP 8.3 and more recent.
LC_TIME: and the dates too
strftime() reads LC_TIME for month names, weekday names, and AM/PM markers.
<?php
$ts = strtotime('2025-03-15 14:30');
setlocale(LC_TIME, 'fr_FR.UTF-8');
echo strftime('%A %d %B %Y', $ts);
// samedi 15 mars 2025
?>
| Locale | strftime('%A, %B %d, %Y') |
|---|---|
| en_US | Saturday, March 15, 2025 |
| fr_FR | samedi 15 mars 2025 |
| de_DE | Samstag, 15. März 2025 |
| ja_JP | 土曜日, 3月15日, 2025 |
| ar_SA | السبت، 15 مارس 2025 (right-to-left) |
strftime() was deprecated in PHP 8.1 and is scheduled to leave core entirely, following the exact path money_format()already walked. The replacement, IntlDateFormatter, again takes its locale as a constructor argument rather than reading an ambient global — the same pattern NumberFormatter uses, which is not a coincidence. Every LC_*-dependent function the intl extension has replaced has been replaced with one that stops trusting setlocale().
LC_MESSAGES: translations, when gettext is here
gettext() looks up translated strings in .mo catalogs, keyed by whatever LC_MESSAGES currently says. This is the one category that isn’t really about formatting at all: it’s entirely internationalization, or i18n. It’s also the one most likely to simply not be there: it only works if PHP was built --with-gettext. And if you don’t have a distinct system for translations.
<?php
bindtextdomain('myapp', './locales');
textdomain('myapp');
setlocale(LC_MESSAGES, 'fr_FR.UTF-8');
echo _('Welcome!');
// Bienvenue !
?>
setlocale(LC_MESSAGES, 'fr_FR.UTF-8')sets the lookup locale._('Welcome!')triggers a lookup by msgid.- PHP searches
./locales/fr_FR/LC_MESSAGES/myapp.mo. - It returns the translation — or silently falls back to the original English string if the catalog or the entry is missing.
That silent fallback is the sting: a missing .mo file doesn’t error, it just serves English to a French user and waits for someone to notice. Always guard with function_exists('gettext') before relying on this category; it’s the one part of setlocale() that can be entirely absent depending on how PHP was compiled, not just how it was configured.
important points about setlocale() and its constants
They’re not six independent settings: they’re six views onto one process-wide value. setlocale() doesn’t take a scope, a request ID, or a callback context. It mutates the same C global every other request in the same process will read next, which is invisible on classic PHP-FPM (one process, one request, the state dies with the process) and very much not invisible on anything that keeps a worker alive across requests: I’m looking at you, RoadRunner, Swoole, FrankenPHP, and the others.
Set LC_NUMERIC to de_DE on request #4012 of a long-lived worker and forget to reset it, and request #4013 inherits a decimal comma it never asked for. It’s the same category of bug as leftover superglobal state in a coroutine worker, just for a global almost nobody thinks to audit.
The intl extension isn’t a replacement for the same thing: it’s the industry quietly admitting it could do something even more complex. NumberFormatter and IntlDateFormatter both take the locale as an explicit constructor argument. That single change, locale as a parameter instead of ambient global state, is why they can run correctly, one call after another, in the exact long-lived worker that makes setlocale() unsafe. Every function intl has replaced, like money_format(), and soon strftime(), was replaced with this pattern specifically.
Almost nobody restores what they changed. setlocale() returns the previous setting for exactly this reason: $old = setlocale(LC_ALL, 0) followed by a restore at the end of the function is the correct pattern: in fact, I can see #[NoDiscard] usage for this value, if it was not breaking so many codes.
Almost no code in the wild capture it and reuses it, because a locale bug doesn’t crash anything. It just makes a French invoice look slightly american state unionist, or a sort order look slightly wrong, or a float silently lose its fractional part. Nothing loud enough to fail a test suite; plenty loud enough to fail an audit six months later.
The bigger picture
setlocale() is a 1990s C API that PHP exposed more or less verbatim, and for twenty years that was a reasonable trade: one process per request meant global mutable state cost you nothing, because it never survived long enough to leak anywhere. That assumption is the part that’s aged badly, not the function itself. The locale categories are exactly as coherent as they were in 1993; it’s the execution model around them that quietly stopped matching.
Every long-lived-worker runtime PHP has grown in the last few years inherits this function completely unchanged, along with its assumption that nobody’s still using it three requests later. Auditing a codebase for stray setlocale() calls that never get restored is a fairly cheap static check to write. Whether anyone runs it before switching their app to a worker-based SAPI is a different question entirely.
