PHP-Entwickler in Geneva

PHP-Entwicklung für Genf von einer Person, remote und schriftlich: Zahlen, Franken, Datum, Sortierung und Plural für de-CH und fr-CH mit NumberFormatter, IntlDateFormatter, Collator und MessageFormatter.

PHP-Entwickler

Mein Platz in der WordPress-Community

  • Mein wordpress.org-Konto besteht seit 2006.
  • Ich habe zum WordPress-Core in den Versionen 7.0 und 7.1 beigetragen.
  • Ich betreue 30 Plugins auf wordpress.org.
  • Ich organisiere WordCamps und trete dort als Speaker auf.
  • Ich war bei jedem WordCamp Europe von 2017 bis 2026 dabei.

Mein WordPress.org-Profil

Wer in Genf auf Deutsch und Französisch verkauft, merkt Fehler in der PHP-Anwendung zuerst an kleinen Dingen: ein Betrag mit deutschem Punkt, ein Datum mit falschem Wortlaut, eine Liste, in der Zürich hinter Zug steht. Auf dieser Seite geht es um genau diese Schicht, die PHP-Erweiterung intl. Alle Ausgaben unten habe ich mit PHP 8.4.27 und ICU 78.3 selbst erzeugt.

Hosting, Plugins für WPML oder Polylang und hreflang sind ein anderes Thema und stehen auf der Seite WooCommerce für Genf. Hier geht es um Code.

#Welche Sprachen muss eine Genfer Anwendung bedienen?

Das Bundesamt für Statistik nennt für 2020 als Hauptsprache bei 62 Prozent der ständigen Wohnbevölkerung Deutsch oder Schweizerdeutsch, bei 23 Prozent Französisch, bei 8 Prozent Italienisch und bei 0,5 Prozent Rätoromanisch. Für 2024 gibt das Amt an, dass 28 Prozent der Erwerbstätigen bei der Arbeit üblicherweise mehrere Sprachen sprechen und 24 Prozent davon Englisch. Eine Genfer Firma, die in der ganzen Schweiz verkauft, braucht also schnell mindestens de-CH und fr-CH und oft Englisch dazu.

Ratssaal im Palais des Nations in Genf

Foto: Nacaru, CC BY-SA 4.0, Wikimedia Commons.

Die Frage für den Code lautet: Welche Teile der Anwendung hängen von der Sprache ab? Texte sind offensichtlich. Dazu kommen Zahlen, Beträge, Datum, Sortierung und Plural. Für diese vier liefert die intl-Erweiterung fertige Regeln, statt sie von Hand nachzubauen. Die Anforderung steht im PHP-Handbuch: intl baut auf der ICU-Bibliothek auf, ab PHP 7.4 ist ICU 50.1 oder neuer nötig. Die Formate stammen also aus der Bibliothek auf Ihrem Server, und ein anderer ICU-Stand kann andere Zeichen liefern.

#Zahlen und Franken mit NumberFormatter

NumberFormatter nimmt ein Locale und einen Stil. Mit zwei Zeilen lässt sich der Unterschied zwischen den Sprachregionen zeigen:

$zahl = new NumberFormatter('fr_CH', NumberFormatter::DECIMAL);
$chf  = new NumberFormatter('fr_CH', NumberFormatter::CURRENCY);

echo $zahl->format(1234567.891), "\n";           // 1'234'567,891
echo $chf->formatCurrency(1234.5, 'CHF'), "\n";  // 1'234.50 CHF

Dieselben Werte für vier Locales, gemessen mit PHP 8.4.27 und ICU 78.3:

LocaleZahl 1234567.891Betrag 1234.5 in CHF
de_CH1’234’567.891CHF 1’234.50
fr_CH1’234’567,8911’234.50 CHF
it_CH1’234’567.891CHF 1’234.50
de_DE1.234.567,8911.234,50 CHF

Drei Punkte fallen auf. Erstens trennt die Schweiz die Tausender mit dem Apostroph, nicht mit Punkt oder Leerzeichen. Zweitens steht der Franken im Deutschen vor dem Betrag und im Französischen dahinter. Drittens hat fr_CH in dieser ICU-Version für die Dezimalzahl ein Komma und für den Betrag einen Punkt. Das sind zwei getrennte Einträge in den Locale-Daten, und wer ein Format hart codiert, trifft den falschen. Zwischen Betrag und Währung steht in der Ausgabe ein geschütztes Leerzeichen (U+00A0), in der Tabelle als normales Leerzeichen gesetzt. Ein Test, der den String vergleicht, muss das Zeichen kennen.

Beim Einlesen ist ein Verhalten gefährlich, das ich ebenfalls gemessen habe:

$f = new NumberFormatter('de_CH', NumberFormatter::DECIMAL);
var_dump($f->parse("1'234.5"));  // float(1234.5)
var_dump($f->parse('1234,5'));   // float(1234)

Tippt ein Kunde das deutsche Komma in ein de_CH-Feld, liest parse() bis zum Komma und gibt 1234 zurück, ohne Ausnahme. Für Rechnungsbeträge reicht das, um eine falsche Zahl in die Datenbank zu schreiben. Felder, die beide Schreibweisen annehmen sollen, prüfe ich deshalb vor dem Parsen. Beträge speichere ich nicht als Float, das ist ein eigenes Thema; NumberFormatter ist für die Anzeige und die Eingabe da.

#Datum und Uhrzeit mit IntlDateFormatter

Der Konstruktor von IntlDateFormatter nimmt Locale, Datumsstil, Zeitstil und eine Zeitzone. Für den 11. November 2026, 14:30 Uhr in Europe/Zurich ergaben sich mit dem Stil LONG und SHORT:

LocaleAusgabe
de_CH11. November 2026 um 14:30
fr_CH11 novembre 2026 à 14:30
it_CH11 novembre 2026 alle ore 14:30

Der Wortlaut des Verbinders (“um”, “à”, “alle ore”) und der Punkt hinter dem Tag kommen aus den Locale-Daten. Ein festes Muster wie EEEE, d. MMMM y trägt dagegen den deutschen Punkt ins Französische: Der Test lieferte “mercredi, 11. novembre 2026”. Deshalb nehme ich die Stile statt eigener Muster, wo es das Layout erlaubt. Muss ein Muster sein, bekommt jedes Locale sein eigenes.

Die Zeitzone gebe ich immer ausdrücklich an. Das PHP-Handbuch nennt als Vorgabe die Zeitzone von date_default_timezone_get(), und ein Server in einer anderen Zone verschiebt sonst die Uhrzeit.

#Sortieren mit Collator

PHP-sort() vergleicht Bytes. Mit einer Liste aus Genfer und Zürcher Namen zeigt sich der Unterschied:

$namen = ['Zürich', 'Zurich', 'Zug', 'Äpfel', 'Apfel', 'Ecole', 'École', 'Émile', 'Eva', 'Genève', 'Geneve', 'Gent'];

sort($namen);
// Apfel Ecole Eva Geneve Gent Genève Zug Zurich Zürich Äpfel École Émile

$c = new Collator('fr_CH');
$c->sort($namen);
// Apfel Äpfel Ecole École Émile Eva Geneve Genève Gent Zug Zurich Zürich

Nach Bytes landen Wörter mit Akzent oder Umlaut hinter dem Z. Collator stellt sie neben die Grundbuchstaben. Für de_CH und fr_CH ergab die Probe dieselbe Reihenfolge.

Zwei Einstellungen lohnen sich in Rechnungslisten. Mit Collator::NUMERIC_COLLATION (im Klassenverzeichnis des PHP-Handbuchs aufgeführt) sortiert “Rechnung 2” vor “Rechnung 10”; ohne die Option ergab die Probe 1, 10, 2. Und bei Französisch lohnt es, das Locale genau zu wählen: Für die Wörter côte, cote, coté und côté lieferte fr_CA eine andere Reihenfolge (cote côte coté côté) als fr_CH (cote coté côte côté). Ein Collator für “fr” überall ist darum eine Entscheidung, die ich mit Ihnen schriftlich abstimme.

#Plural und Platzhalter mit MessageFormatter

Ein Satz wie “3 Rechnungen” wird oft mit if ($n === 1) gebaut. Das PHP-Handbuch zeigt für MessageFormatter::formatMessage die ICU-Syntax mit plural. Mit diesem Muster habe ich Deutsch und Französisch geprüft:

$muster = '{n, plural, one {# facture} other {# factures}}';
foreach ([0, 1, 2] as $n) {
    echo MessageFormatter::formatMessage('fr', $muster, ['n' => $n]), "\n";
}
// 0 facture
// 1 facture
// 2 factures

Im Deutschen ergab die 0 “0 Rechnungen” (Form other), im Französischen “0 facture” (Form one). Eine Logik “nur bei 1 der Singular” stimmt für Französisch also nicht. Bei einer Million lieferte das Muster mit der zusätzlichen Form many in ICU 78.3 “1 000 000 de factures”: Französisch hat für grosse Zahlen eine dritte Form mit “de”. Wer Zahlen in Statistik oder Berichten ausschreibt, braucht sie.

Platzhalter wie {name} stehen im selben Muster. Der Übersetzer sieht den ganzen Satz, nicht Satzteile, die der Code zusammensetzt.

#gettext oder Symfony Translation

Zwei Wege sind verbreitet. Bei gettext liefert ngettext($singular, $plural, $count) laut PHP-Handbuch die richtige Pluralform, und das Handbuch merkt an, dass manche Sprachen mehr als eine Pluralform haben. Das passt zu Projekten ohne Framework und zu Übersetzern, die mit PO-Dateien arbeiten.

In Symfony ist die Translation-Komponente der kürzere Weg. Zwei Punkte aus der Dokumentation sind für Genf wichtig:

  • ICU-Platzhalter mit {name} verlangen den Zusatz +intl-icu im Dateinamen, zum Beispiel messages+intl-icu.fr.yaml.
  • Symfony bringt Polyfills für intl mit, aber sie unterstützen nur Englisch. Für Deutsch und Französisch muss die PHP-Erweiterung installiert sein. Auf einem Hosting ohne intl fällt das sonst erst im Test auf.

Für den Fallback beschreibt die Dokumentation am Beispiel es_AR die Kette: zuerst die Datei für es_AR, dann das Eltern-Locale, dann die Sprache ohne Region (es), dann die Einstellung fallbacks. Übertragen auf Genf heisst das: Eine Datei messages.fr.yaml bedient fr_CH, solange keine Datei für fr_CH existiert. Ich lege deshalb die Basis in fr und de an und ergänze fr_CH oder de_CH nur dort, wo die Schweizer Schreibweise abweicht.

#Welches Locale bekommt der Besucher?

PHP bietet Locale::acceptFromHttp(), das aus dem Accept-Language-Header ein Locale bestimmt, und Locale::lookup(), das nach dem Verfahren aus RFC 4647 den besten Treffer in einer Liste sucht. Zwei Proben:

echo Locale::acceptFromHttp('fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7');  // fr_CH
echo Locale::lookup(['de', 'fr', 'it'], 'fr_CH', true, 'de');        // fr

Symfony kann das mit der Sprache aus dem Header ebenfalls und wählt laut Dokumentation bei fehlendem exaktem Treffer eine Teilübereinstimmung über die Sprache. Wichtig ist die Reihenfolge der angebotenen Sprachen, denn ohne Treffer gilt die erste. Die Sprache des Besuchers sollte ausserdem in der URL oder im Konto stehen und nicht nur im Header, sonst sieht der Besucher bei jedem Gerät etwas anderes.

#Checkliste vor der Abnahme

  • Auf dem Zielserver INTL_ICU_VERSION ausgeben und die Formattests mit genau diesem Stand laufen lassen.
  • Betrag, Datum und Sortierung für de_CH und fr_CH in je einem Test festhalten, einschliesslich des geschützten Leerzeichens.
  • Eingabefelder mit Komma und Punkt testen und festlegen, was bei dem anderen Zeichen passiert.
  • Pluralmuster für 0, 1, 2 und eine grosse Zahl in beiden Sprachen prüfen.
  • Zeitzone überall ausdrücklich setzen.
  • Bei Symfony: intl auf Testsystem und Produktion installiert, Dateien mit +intl-icu benannt.

#Wie ein Projekt mit mir abläuft

Sie schicken mir schriftlich Ziel, Sprachen, vorhandenen Stack und Termin. Ich antworte mit Rückfragen und einem schriftlichen Umfang samt Annahmen. Gebaut wird auf einer Testumgebung, die Abnahme geschieht anhand der Checkliste oben. Gestaltung und Layouts liefern Sie, ich setze sie um. Der Code geht mit README und Tests an Ihr Repository.

#Häufige Fragen

#Reicht setlocale() und number_format() für einen Genfer Shop?

Für einfache Fälle ja, aber number_format() kennt keine Locale und setlocale() hängt von den Sprachen ab, die der Server installiert hat. NumberFormatter bringt die Regeln für de_CH und fr_CH aus der ICU-Bibliothek mit und trennt Zahl, Währung und Locale sauber.

#Warum zeigt fr_CH den Frankenbetrag anders als die Dezimalzahl?

Das sind zwei getrennte Formate in den Locale-Daten. Mit PHP 8.4.27 und ICU 78.3 ergab fr_CH 1’234’567,891 für die Zahl und 1’234.50 CHF für den Betrag. Prüfen Sie die Ausgabe auf Ihrem Server, statt ein Format hart zu codieren.

#Was passiert, wenn ein Kunde 1234,5 in ein de_CH-Feld tippt?

NumberFormatter::parse liest nur bis zum Komma und gibt 1234 zurück, ohne Ausnahme. Ein Feld, das Eingaben in beiden Schreibweisen annimmt, braucht eine eigene Prüfung vor dem Parsen.

#Soll ich gettext oder Symfony Translation nehmen?

Hängt am Projekt. In Symfony ist Translation der kürzere Weg, weil Extraktion und Fallback dabei sind. In einem Projekt ohne Framework mit Übersetzern, die PO-Dateien kennen, ist gettext mit ngettext() eine Option. Beide brauchen für Plural die Regeln der Zielsprache.

#Reicht eine Datei messages.fr.yaml für Genf?

Meistens ja. Die Symfony-Dokumentation beschreibt die Fallback-Kette vom Locale mit Region über das Eltern-Locale zur Sprache ohne Region. Eine Datei für fr_CH lohnt sich nur, wo Genf wirklich anders schreibt.

#Arbeiten Sie vor Ort in Genf?

Nein. Ich arbeite als Einzelperson von Gdynia aus, remote und schriftlich. Gestaltung und Layouts liefert der Kunde, ich setze sie um.

#Schreiben Sie mir

Schreiben Sie mir über die Kontaktseite die Adresse Ihrer Anwendung, die Sprachen, die sie bedienen soll, Ihren Stack und einen Termin, falls es einen gibt. Ich lese den Code oder die Beschreibung und antworte schriftlich. Mein erstes schriftliches Urteil zu Ihrem Vorhaben kostet nichts.

Siehe auch in Schweiz

Brauchen Sie die Leistung: PHP-Entwickler in Geneva?

Beschreiben Sie Ihr Projekt per E-Mail. Antwort innerhalb von 48 Stunden.

Projekt beschreiben

Weitere WordPress-Dienste und Wissensbasis entdecken