· 8 min read

Laravel trans_choice() In Depth: Pluralization That Doesn't Break in Arabic or Polish

Most Laravel developers learn trans_choice() from the two-form example in the docs and assume it generalises. It doesn't. 'apple|apples' is correct for English and wrong for roughly half the languages you're likely to ship in, and the failure is silent: no exception, no log line, just the wrong noun in front of a user who notices immediately.

The reason is that Laravel's pluralization isn't driven by the standard you probably assume it is. Let's look at what actually happens when you call trans_choice(), then write forms that hold up.

What trans_choice() really does

The work happens in Illuminate\Translation\MessageSelector::choose(). Given a line, a number and a locale, it does three things in order:

  1. Looks for an explicit condition matching the number — {0}, {1}, [2,19], [20,*]. If one matches, that segment wins and nothing else is consulted.
  2. Strips conditions from the remaining segments.
  3. Asks getPluralIndex($locale, $number) for a numeric index into the pipe-separated segments, and returns that segment.

Step 3 is where the assumptions break. Here's the method's own docblock, verbatim from the framework:

The plural rules are derived from code of the Zend Framework (2010-09-25), which
is subject to the new BSD license (https://framework.zend.com/license)

Laravel's plural rules are a hardcoded switch over locale strings, ported from a 2010 Zend Framework table. They are not CLDR, and they are not ICU. They agree with CLDR for most languages, and disagree for some — which is exactly the situation that produces bugs you only find in production.

The Polish problem

CLDR defines four cardinal categories for Polish: one, few, many, other. Here's Laravel's rule:

case 'pl':
case 'pl_PL':
    return ($number == 1) ? 0 : ((((int) $number % 10 >= 2) && ((int) $number % 10 <= 4) && (((int) $number % 100 < 12) || ((int) $number % 100 > 14))) ? 1 : 2);

Three possible return values: 0, 1, 2. So a Polish lang file needs three pipe-separated forms under Laravel, not the four a CLDR-based tool will tell you to supply:

// lang/pl/cart.php
return [
    // 1 produkt | 2-4 produkty | 5+ produktów
    'items' => ':count produkt|:count produkty|:count produktów',
];

If you dutifully write four forms because a localization platform exported four CLDR categories, the fourth is dead code — Laravel never asks for index 3 in Polish. If you write two, you get the bug in the next section.

Arabic, for what it's worth, does line up on count. Laravel returns 0–5 for ar, six forms, matching CLDR's six categories for Arabic:

return ($number == 0) ? 0 : (($number == 1) ? 1 : (($number == 2) ? 2 :
    ((((int) $number % 100 >= 3) && ((int) $number % 100 <= 10)) ? 3 :
    ((((int) $number % 100 >= 11) && ((int) $number % 100 <= 99)) ? 4 : 5))));

So an Arabic lang file needs six segments:

// lang/ar/cart.php
return [
    'items' => 'لا عناصر|عنصر واحد|عنصران|:count عناصر|:count عنصرًا|:count عنصر',
];

Note the first form handles zero. In Arabic that's grammar, not a UX nicety — which is why copying English's two-form habit produces text that reads as broken rather than merely terse.

The silent fallback that hides the bug

Here's the part that turns a small mistake into a long-lived one. From choose():

if (count($segments) === 1 || ! isset($segments[$pluralIndex])) {
    return $segments[0];
}

If the rule asks for an index you didn't supply, Laravel returns the first segment. No exception, no warning. So a Polish line with only two forms renders the singular for every count of 5 or more:

// lang/pl/cart.php — WRONG, only two forms
'items' => ':count produkt|:count produkty',
trans_choice('cart.items', 5, [], 'pl');
// index 2 requested, doesn't exist → falls back to segment 0
// "5 produkt" — wrong, should be "5 produktów"

Your tests pass, because you wrote them with counts of 1 and 2.

The hyphen trap

getPluralIndex() is a switch on the locale string. It contains 280 case labels, and every one of them is underscored or barept_BR, zh_CN, pl_PL, es_MX. Not one uses a hyphen. The default branch returns 0.

That matters because hyphens are the BCP 47 convention the outside world uses: pt-BR, zh-CN, es-MX.

One piece of good news first, because it's easy to assume the worst here. $request->getPreferredLanguage() is not a source of this bug — Symfony normalizes its return value to underscores, so it hands you pt_BR even when you pass a hyphenated list:

$request->headers->set('Accept-Language', 'pt-BR,pt;q=0.9');

$request->getPreferredLanguage(['en', 'pt-BR']);  // "pt_BR" — already underscored

Worth knowing about that method, though: when nothing matches it returns the first element of the array you passed, not null. So put your default first and don't rely on a ?? fallback that will never fire.

The hyphens come from everywhere else:

  • A URL segment/pt-BR/dashboard is a normal way to express a locale in a path.
  • navigator.language in the browser, which returns pt-BR, sent to your API in a header, query string or JSON body.
  • A stored user preference seeded from either of the above.
  • .env, if someone hand-writes APP_LOCALE=pt-BR.

So this looks reasonable and quietly disables pluralization:

// $locale is "pt-BR" from the route segment.
// Every trans_choice() call now returns the first form, always.
public function handle(Request $request, Closure $next)
{
    App::setLocale($request->route('locale'));

    return $next($request);
}

Normalise before you set the locale:

public function handle(Request $request, Closure $next)
{
    $locale = str_replace('-', '_', (string) $request->route('locale'));

    abort_unless(in_array($locale, config('app.available_locales')), 404);

    App::setLocale($locale);

    return $next($request);
}

Two things there. Underscoring is the actual fix. The allow-list is worth having anyway — the value came from the URL and ends up in setLocale(), and validating against known locales means an unrecognised one gives you a 404 instead of silently falling through to index 0.

Anywhere a locale enters your app from outside — a path segment, a cookie, a JSON body, navigator.language — underscore it and check it against a list. It's a small fix for a bug that's nearly invisible in review, because the code reads correctly and the output is only wrong for counts you didn't happen to try.

Explicit ranges: the escape hatch worth using

Because explicit conditions are matched before any locale rule runs, they behave identically in every language. That makes them the reliable choice whenever you need exact control:

return [
    'notifications' => '{0} No notifications|{1} One notification|[2,*] :count notifications',
];
trans_choice('cart.notifications', 0);  // "No notifications"
trans_choice('cart.notifications', 1);  // "One notification"
trans_choice('cart.notifications', 7);  // "7 notifications"

Two things to know. First, {0} is genuinely useful: bare pipe forms have no zero case in English, so 0 takes the plural branch and you get "0 items" unless you write the condition. Second, ranges are a blunt instrument for inflected languages — [2,*] flattens the distinction between Polish's produkty and produktów. Use explicit conditions for zero-states, thresholds and copy where you want a guarantee; use pipe forms where you need real grammar.

A pattern that works well in practice is combining them:

// English: explicit zero, then let the rule handle the rest.
'items' => '{0} Your cart is empty|:count item|:count items',

The {0} matches first for zero. For everything else, the conditions are stripped and the remaining two segments are indexed by the English rule.

Placeholders and :count

trans_choice() passes :count automatically, but any other placeholder you use must be supplied explicitly:

// lang/en/cart.php
'items_for' => '{0} :name has no items|:name has :count item|:name has :count items',
trans_choice('cart.items_for', $count, ['name' => $user->name]);

One gotcha worth knowing: the number Laravel uses for selection is the raw value you pass, while :count is substituted as-is. If you want a thousands-separated display value, format it yourself and pass it as a separate placeholder — replacing :count with a formatted string would break selection for anything that re-parses it.

A test that would have caught all of this

Pluralization bugs are cheap to test and expensive to find in production. Assert the boundaries per language, not just 1 and 2:

it('pluralizes cart items correctly in polish', function () {
    $cases = [
        1 => '1 produkt',
        2 => '2 produkty',
        4 => '4 produkty',
        5 => '5 produktów',
        12 => '12 produktów',
        22 => '22 produkty',
    ];

    foreach ($cases as $count => $expected) {
        expect(trans_choice('cart.items', $count, [], 'pl'))->toBe($expected);
    }
});

22 => produkty is the case that catches a rule you copied from Russian, and 12 => produktów catches the % 100 < 12 clause. If you only test 1 and 2, every wrong implementation in this post passes.

Checking your language before you write the file

Two things are worth knowing before you write a plural line for a language you don't speak: how many grammatical forms it has, and which numbers map to which form. Our pluralization tester answers both — pick a language, and it shows the CLDR categories your browser reports plus the category for every number from 0 to 24, so you can see exactly where the boundaries fall.

Then check the count against Laravel's rule, because as we've seen the two don't always agree. The per-language Laravel guides list the form count Laravel actually expects for each language, with a worked trans_choice example.

What to take away

  • Laravel's plural rules come from a 2010 Zend Framework table, not CLDR. Verify the form count per language instead of trusting a CLDR export.
  • Too few forms fails silently to the first segment. Nothing warns you.
  • Normalise hyphens to underscores before App::setLocale(), or pluralization dies for every regional locale.
  • Explicit conditions ({0}, [2,*]) run before locale rules and behave the same everywhere — use them for zero-states and thresholds.
  • Test boundary counts per language: 1, 2, 5, 12, 22. Not just 1 and 2.

If you're maintaining plural forms across more than a couple of languages, the file-by-file approach stops scaling quickly — every new language means finding the right form count, writing the segments, and hoping nobody drops one in a merge. LangSyncer keeps every language's forms next to the key, fills the gaps with AI, and publishes corrections to production in seconds without a deploy, which is a much better place to be when you discover a plural bug on a Friday afternoon.

We use cookies to improve your experience and analyze site traffic. Cookie Policy

Cookie Preferences

Essential

Required for the site to work

Analytics

Help us improve the site

Marketing

Personalized ads and content