Eine Mitgliederliste sortiert „Özdemir“ hinter „Zander“, und ein CSV-Export schreibt seit dem Einbinden einer neuen Bibliothek Kommas in die Preisspalte. Beides hängt an derselben Einstellung, der Locale des PHP-Prozesses. Wie man Datum, Zahlen und Währungen je Sprache ausgibt, zeigen datefmt_create() und numfmt_create(). Darunter liegt die Einstellung, die PHP setlocale() umstellt und die einige ältere Funktionen still mitlesen.
Seit PHP 8 sind das nur noch wenige Funktionen. Und der Name, der unter Windows funktioniert, liefert unter Linux häufig nur false.
Was PHP setlocale() macht
PHP setlocale() bekommt eine Kategorie und einen oder mehrere Namen. Sie probiert die Namen der Reihe nach, stellt die Kategorie auf den ersten um, den das Betriebssystem kennt, und gibt diesen Namen zurück. Kennt das System keinen davon, kommt false, und die bisherige Einstellung bleibt unverändert. Locales sind nämlich kein Teil von PHP, sondern Dateien des Betriebssystems. Deshalb gehört die Prüfung des Rückgabewerts in jeden Aufruf:
<?php
$locale = setlocale(LC_ALL, [
'de_DE.UTF-8', /* Linux */
'de_DE.utf8',
'de-DE.UTF-8', /* Windows 10 und neuer */
]);
if ($locale === false) {
error_log('Keine deutsche UTF-8-Locale');
} else {
echo $locale; /* z. B. de_DE.UTF-8 */
}
Mit installierter deutscher Locale antwortete Linux mit de_DE.UTF-8, Windows mit de-DE.UTF-8. Der Ablauf eines Aufrufs von PHP setlocale() sieht so aus:
flowchart TD
A[setlocale mit Kategorie und Namen]
B{Name auf dem System vorhanden?}
C{weitere Namen in der Liste?}
D[false, nichts geändert]
E[Kategorie umstellen]
F[Namen zurückgeben]
G[gilt im ganzen Prozess]
H[Requestende: zurück zum Start]
A --> B
B -->|ja| E
E --> F
F --> G
G --> H
B -->|nein| C
C -->|ja| B
C -->|nein| D
Die Signatur im Original
Das Handbuch führt zwei Schreibweisen, einmal mit einzelnen Namen und einmal mit einem Array:
setlocale(
int $category,
string $locales,
string ...$rest
): string|false
setlocale(
int $category,
array $locale_array
): string|false
/* Set locale information.
$category LC_ALL, LC_COLLATE, LC_CTYPE,
LC_MONETARY, LC_NUMERIC, LC_TIME,
LC_MESSAGES (not on Windows)
$locales locale name; further names or an
array are tried in order
"" = locale from the environment
"0" = return the current setting
returns the new current locale, or false */
Der String '0' ändert nichts und liefert nur die aktuelle Einstellung. Der leere String übernimmt die Locale aus der Umgebung, unter Linux aus Variablen wie LANG, unter Windows aus den Regionseinstellungen. Ohne diesen Aufruf startet PHP mit der Locale C, auch wenn LANG=de_DE.UTF-8 gesetzt ist.
Die LC-Kategorien und was sie steuern
LC_ALL stellt alle auf einmal um. Wer gezielter arbeitet, ändert nur die eine, die er braucht:
| Kategorie | Wirkt in PHP 8 auf | Gemessen mit deutscher Locale |
LC_NUMERIC | printf() und sprintf() mit %f, %g und %G, localeconv() | 1234,50 statt 1234.50 |
LC_COLLATE | strcoll(), sort() mit SORT_LOCALE_STRING | Äpfel vor Birne statt hinter Zitrone |
LC_CTYPE | Zeichenklassen in ctype_alpha() und Co., iconv() mit Umschrift, escapeshellarg() | aus Ärger wird AErger |
LC_MONETARY | Währungsangaben in localeconv() | €, zwei Nachkommastellen |
LC_TIME | strftime(), seit PHP 8.1 abgekündigt | Sonntag statt Sunday |
LC_MESSAGES | Systemmeldungen und gettext, nur unter Linux | unter Windows nicht definiert |
Die Konstanten tragen je nach System andere Zahlen. Unter Linux ist LC_ALL die 6, unter Windows die 0, und LC_MESSAGES führt unter Windows zu Error: Undefined constant "LC_MESSAGES". Zahlen statt Namen stellen auf dem anderen System eine falsche Kategorie um.
Locale abfragen und Namen unter Linux und Windows
Zum Abfragen dient der String '0'. Unter Linux kommt dabei schon beim Start ein langer Sammelstring heraus, weil PHP die Zeichenklassen selbst auf C.UTF-8 stellt. Eine häufige Abkürzung ist dagegen falsch:
<?php
echo setlocale(LC_ALL, '0');
/* Linux: LC_CTYPE=C.UTF-8;LC_NUMERIC=C;...
Windows: C */
setlocale(LC_ALL, null);
/* fragt NICHT ab, sondern setzt die Locale
aus der Umgebung, unter Windows etwa
German_Germany.1252 */
Der Wert null wirkt wie der leere String. Im Linux-Container fiel LC_CTYPE dadurch von C.UTF-8 auf C, und escapeshellarg('Äpfel') lieferte danach nur noch 'pfel'. Ein Locale-Name besteht aus dem Sprachkürzel nach ISO 639 (de), dem Ländercode (DE) und dem Zeichensatz (UTF-8). Linux verbindet Sprache und Land mit einem Unterstrich, Windows schreibt sie wie ein Sprach-Tag nach BCP 47 mit Bindestrich:
| Name | Windows | Linux, Docker-Image | Linux mit locale-gen |
de_DE.UTF-8 | false | false | gesetzt |
de-DE.UTF-8 | gesetzt | false | false |
de-DE, German | gesetzt, Codepage 1252 | false | nur mit zusätzlich erzeugtem Latin-1, sonst false |
de_DE | false | false | nur mit zusätzlich erzeugtem Latin-1, sonst false |
C.UTF-8 | false | gesetzt | gesetzt |
erfunden: de-XX, zz | gesetzt | false | false |
Die letzte Zeile ist die unangenehmste. Windows quittiert jedes formal gültige Sprachkürzel mit Erfolg, auch eines, das es nicht gibt. Ein Rückgabewert ungleich false beweist dort wenig. Außerdem gehören nur UTF-8-Namen in die Kandidatenliste: Mit de-DE und damit Codepage 1252 sortierte Windows eine Liste aus UTF-8-Wörtern falsch, mit de-DE.UTF-8 richtig. Unter Linux lieferte localeconv() mit Latin-1 zudem EUR statt des Euro-Zeichens, unter Windows mit Codepage 1252 das einzelne Byte 0x80.
Wenn PHP setlocale() false liefert
Meist fehlt die gewünschte Locale auf dem Server. Welche vorhanden sind, verrät unter Linux der Befehl locale -a. Im offiziellen PHP-Image, das auf Debian aufsetzt, stehen dort nur C, C.utf8 und POSIX. Jeder deutsche Name scheitert also, und sortiert wird stillschweigend weiter nach Bytes. Im eigenen Image lässt sich das nachholen:
RUN apt-get update \
&& apt-get install -y locales \
&& echo "de_DE.UTF-8 UTF-8" >> /etc/locale.gen \
&& locale-gen
Danach stand de_DE.utf8 in der Liste, und PHP setlocale() lieferte für de_DE.UTF-8 den Namen statt false. Auf Shared Hosting bleibt nur locale -a per SSH oder eine Anfrage beim Anbieter.
Was die Locale in PHP 8 noch beeinflusst
Ein Test mit deutscher LC_NUMERIC zeigt, wo die Grenze verläuft:
<?php
setlocale(LC_NUMERIC, ['de_DE.UTF-8', 'de-DE.UTF-8']);
echo sprintf('%.2f', 19.99); /* 19,99 */
echo sprintf('%.2F', 19.99); /* 19.99 */
echo 19.99; /* 19.99 */
echo number_format(19.99, 2); /* 19.99 */
var_dump((float) '19,99'); /* float(19) */
Von den Platzhaltern folgen %f, %g und %G der Locale, das große %F und %e nie. Die Ausgabe einer Zahl mit echo nimmt seit PHP 8.0 immer den Punkt, und eine Formulareingabe mit Komma liest auch eine deutsche Locale nicht als Zahl. Daten aus $_POST bleiben von PHP setlocale() also unberührt. Weitere gemessene Fälle:
- Folgt der Locale:
localeconv(), strcoll(), sort() mit SORT_LOCALE_STRING, ctype_alpha() bei 8-Bit-Daten, unter Linux iconv() mit //TRANSLIT und escapeshellarg(). - Folgt ihr nicht:
date() bleibt englisch, number_format() und json_encode() nehmen den Punkt, strtoupper(), strtolower() und ucfirst() arbeiten seit PHP 8.2 nur noch mit ASCII.
Das Tutorial zu strtoupper() und mb_strtoupper() zeigt, warum für Umlaute ohnehin die mb-Funktionen zuständig sind.
Praxis: deutsch sortieren und Zahlen mit localeconv()
Der sinnvollste Einsatz von PHP setlocale() ist eine Sortierung nach deutscher Gewohnheit. Damit die Einstellung keine anderen Programmteile trifft, wird sie vorher gesichert und danach zurückgesetzt:
<?php
$obst = ['Zitrone', 'Äpfel', 'apfel', 'Birne',
'Öl', 'Ofen', 'Zebra', 'äpfel'];
$alt = setlocale(LC_ALL, '0');
setlocale(LC_COLLATE, ['de_DE.UTF-8', 'de-DE.UTF-8']);
sort($obst, SORT_LOCALE_STRING);
setlocale(LC_ALL, $alt);
echo implode(', ', $obst);
/* apfel, äpfel, Äpfel, Birne, Ofen, Öl,
Zebra, Zitrone
ohne Locale: Birne, Ofen, Zebra, Zitrone,
apfel, Äpfel, Öl, äpfel */
Für Zahlen liefert localeconv() die Trennzeichen der aktuellen Locale, die number_format() dann ausdrücklich übergeben bekommt:
<?php
setlocale(LC_NUMERIC, ['de_DE.UTF-8', 'de-DE.UTF-8']);
$lc = localeconv();
echo number_format(
1234567.891,
2,
$lc['decimal_point'],
$lc['thousands_sep']
); /* 1.234.567,89 */
Spürbar wirkt die Locale auch bei der Umschrift für sprechende URLs:
<?php
setlocale(LC_CTYPE, ['de_DE.UTF-8', 'C.UTF-8']);
$ascii = iconv(
'UTF-8',
'ASCII//TRANSLIT',
'Ärger über Café'
);
echo $ascii;
/* de_DE.UTF-8: AErger ueber Cafe
C.UTF-8: Arger uber Cafe
C: ?rger ?ber Caf? */
Unter Linux entscheidet LC_CTYPE also, ob aus einem Umlaut zwei Buchstaben, einer oder ein Fragezeichen werden. Unter Windows hängt das Ergebnis nicht von der Locale ab.
Prozess und Request: wie weit PHP setlocale() reicht
Die Einstellung gilt nicht nur für ein Skript, sondern für den ganzen PHP-Prozess. Ruft eine eingebundene Bibliothek PHP setlocale() auf, trifft das jeden Code, der danach läuft. Derselbe Mechanismus, der in der Einleitung die Preisspalte des CSV-Exports verdorben hat, trifft hier eine SQL-Anweisung:
<?php
function pdfErzeugen(): void
{
setlocale(LC_ALL, ['de_DE.UTF-8', 'de-DE.UTF-8']);
/* ... */
}
pdfErzeugen();
$sql = sprintf(
'UPDATE artikel SET preis = %f',
19.99
);
echo $sql;
/* UPDATE artikel SET preis = 19,990000 */
Das ist ein Syntaxfehler in der Datenbank. Für Maschinenformate gehört deshalb %F in den Formatstring, und Werte für SQL laufen besser über Prepared Statements.
Über das Ende des Requests hinaus wandert die Locale dagegen nicht. Unter Apache mit mod_php und unter dem eingebauten Server php -S setzte ein Request de_DE.UTF-8, und der nächste Request im selben Prozess begann wieder mit dem Ausgangszustand, also C mit Zeichenklassen in C.UTF-8. PHP setzt die Locale beim Requestende zurück. Anders sieht es bei Server-APIs aus, die mehrere Threads in einem Prozess betreiben: Laut Handbuch teilen sich dort alle Threads die Locale, und ein paralleles Skript kann sie mitten im Lauf ändern. Unter Windows trennt PHP die Einstellung seit Version 7.0.5 je Thread.
setlocale() oder intl?
Für neuen Code ist die intl-Erweiterung meist die bessere Wahl. Sie bringt eigene Sprachdaten mit und braucht keine Locale des Betriebssystems. Das zeigt ein Docker-Image ohne deutsche Locale:
<?php
var_dump(setlocale(LC_ALL, 'de_DE.UTF-8'));
/* bool(false), keine Locale im Image */
$fmt = new NumberFormatter(
'de_DE',
NumberFormatter::CURRENCY
);
echo $fmt->formatCurrency(1234.5, 'EUR');
/* 1.234,50 €, mit geschütztem Leerzeichen */
Ein Formatierer trägt seine Sprache selbst, wenn man sie im Konstruktor übergibt. Der Standardwert Locale::getDefault() hängt dagegen am Prozess, denn ICU liest ihn beim ersten Zugriff einmal aus der Locale. Lief vorher setlocale(LC_ALL, 'de_DE.UTF-8'), meldete er de_DE, und zwar auch in allen folgenden Requests desselben Prozesses, sonst blieb er bei en_US_POSIX. Für Zahlen und Währungen ist numfmt_create() der Weg, für Datumsangaben datefmt_create(), und wie der Collator Listen ohne Betriebssystem-Locale sortiert, zeigt der Artikel über das Sortieren von Arrays. Die Locale des Prozesses bleibt für Altfunktionen und Bibliotheken, die sie lesen.
PHP setlocale() von 8.0 bis 8.5
Wer älteren Code aktualisiert, stößt auf diese Änderungen:
| Version | Änderung |
| 8.0 | Fließkommazahlen werden ohne Rücksicht auf die Locale in Text umgewandelt |
| 8.1 | strftime() ist abgekündigt. Bis 8.3 lautet die Meldung Function strftime() is deprecated, ab 8.4 mit dem Zusatz since 8.1, use IntlDateFormatter::format() instead |
| 8.2 | Groß- und Kleinschreibung mit strtolower() und Verwandten nur noch für ASCII |
| 8.5 | die Zahl 0 als zweiter Parameter wirft mit strict_types einen TypeError |
Das Handbuch beschreibt die letzte Zeile allgemeiner. Unter PHP 8.5.8 trat der Fehler nur mit declare(strict_types=1) auf, ohne diese Angabe kam weiter die Abfrage:
<?php
declare(strict_types=1);
setlocale(LC_ALL, 0);
/* PHP 8.5: TypeError: setlocale(): Argument
#2 ($locales) must be of type
array|string|null, int given */
setlocale(LC_ALL, '0'); /* in jeder Version */
Fazit
Von der einst umfassenden Sprachumstellung ist bei PHP setlocale() nicht viel geblieben. Sie steuert das Dezimalkomma in printf(), die Sortierung mit SORT_LOCALE_STRING, die Zeichenklassen und unter Linux die Umschrift von iconv(). Datum, Zahlausgabe mit echo und Großschreibung liegen außerhalb ihrer Reichweite.
Praktisch heißt das: nur UTF-8-Namen in die Kandidatenliste, den Rückgabewert immer prüfen, im Container die Locale nachinstallieren und mit '0' abfragen, nie mit null. Weil die Einstellung den ganzen Prozess trifft, wird sie für einen Block gesichert und danach zurückgesetzt. Für Datum, Zahlen und Währungen in neuem Code ist intl der verlässlichere Weg.