---
title: "setlocale() and Its Six Personalities"
url: https://www.exakat.io/setlocale-and-its-six-personalities/
date: 2026-09-02
modified: 2026-09-02
author: "dams"
description: "[caption id=\"attachment_16362\" align=\"alignleft\" width=\"300\"] setlocale() and Its Six Personalities[/caption] setlocale() and Its Six Personalities What does setlocale() actually do? It changes the date format, right? It's a fair guess, and..."
categories:
  - "Code auditing"
tags:
  - "locale"
  - "modernisation"
  - "php"
image: https://www.exakat.io/wp-content/uploads/2026/09/local.320.jpg
word_count: 1988
---

# setlocale() and Its Six Personalities

[![setlocale() and Its Six Personalities](https://www.exakat.io/wp-content/uploads/2026/09/local.320-300x300.jpg)](https://www.exakat.io/wp-content/uploads/2026/09/local.320.jpg)setlocale() and Its Six Personalities

# 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](https://php-dictionary.readthedocs.io/en/latest/index/global-state.html) 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()`](https://php-dictionary.readthedocs.io/en/latest/index/strtoupper.html), [`strtolower()`](https://php-dictionary.readthedocs.io/en/latest/index/strtolower.html), `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.

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_CTYPE`locale. 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.

| 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](https://github.com/brick/money) component, or read [PHP Number Format Currency – How to Format Currencies in PHP](https://www.koladechris.com/blog/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.

| 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.

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.

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.

`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`:

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.

| 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.

- `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](https://php-dictionary.readthedocs.io/en/latest/index/superglobal.html) 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](https://php-dictionary.readthedocs.io/en/latest/index/locale.html) 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.