Parameters, Plurals and ICU

Placeholders, the ICU message format for plurals and selects, and why 'add an s' is not a plural rule.

Syntax{count, plural, =0 {...} one {...} other {...}}

A placeholder is a slot in a message filled in after the lookup. The translator receives a whole sentence, so a language that needs a different word order can have one.

Two formats

Plain catalogues mark placeholders %name% and replace them with strtr(). ICU MessageFormat catalogues mark them {name} and are handed to PHP's MessageFormatter, which also understands plurals, selects, numbers and dates. ICU is switched on per domain by the filename: messages+intl-icu.fr.yaml rather than messages.fr.yaml.

Plurals are not a suffix

Adding an s is an English rule for most English nouns. Russian picks one of three forms from the last digits of the number. Japanese has one form. English itself usually wants separate wording for zero. ICU's plural function names the CLDR categories — zero, one, two, few, many, other — and the formatter chooses whichever the locale actually defines, so the translator of each file writes only the branches that language has. =0 and =1 match an exact value and beat a category. # prints the number, grouped for the locale.

Shorthand and longhand

# translations/messages.en.yaml (pipe form, used when %count% is numeric)
cart.items: 'One item in your cart|%count% items in your cart'

# translations/messages+intl-icu.en.yaml (same rule, stated)
cart.items: '{count, plural, one {One item in your cart} other {# items in your cart}}'

Both still work. The pipe form is shorter; the ICU form says out loud which rule each branch is for, which is what a translator needs.

What trips people up

Every plural and select needs an other branch, or formatting fails. A literal brace in ICU text must be quoted: '{'.

Example

Example · php
<?php
// src/Cart/CartSummary.php

declare(strict_types=1);

namespace App\Cart;

use Symfony\Contracts\Translation\TranslatorInterface;

/**
 * The catalogue entry this method reads:
 *
 * # translations/messages+intl-icu.en.yaml
 * cart.items: >-
 *     {count, plural,
 *         =0    {Your cart is empty}
 *         one   {One item in your cart}
 *         other {# items in your cart}
 *     }
 */
final class CartSummary
{
    public function __construct(
        private readonly TranslatorInterface $translator,
    ) {
    }

    public function describe(int $count): string
    {
        // ICU placeholder names are bare: 'count', not '%count%'. Passing
        // '%count%' here would leave the plural with nothing to switch on.
        return $this->translator->trans('cart.items', ['count' => $count]);
    }
}

// With the request locale set to en:
//
// $summary->describe(0);  // Output: Your cart is empty
// $summary->describe(1);  // Output: One item in your cart
// $summary->describe(7);  // Output: 7 items in your cart

When to use it

  • Write a 'N items in your cart' line that reads correctly at zero, one and twelve.
  • Give translators a sentence they can restructure, instead of gluing fragments together in PHP.
  • Ship a message to Russian or Polish without assuming English plural rules.
  • Print an ordinal position or a percentage inside a translated sentence.

More examples

select, and a plural nested inside it

select is for a value with a fixed set of possibilities: a gender field, a plan tier, a shipping state. It keeps the branching in the catalogue, where a translator can restructure the whole sentence, instead of in PHP where they cannot reach it. Both functions need an other branch, and in offset:1 the # is the reduced number, not the original.

Example · yaml
# translations/messages+intl-icu.en.yaml

# select switches on a value, like a match expression over strings.
# The branch names are values your code passes, not locale categories,
# so they are the same in every translation of this file.
invitation_title: >-
    {organizer_gender, select,
        female {{organizer_name} has invited you to her party}
        male   {{organizer_name} has invited you to his party}
        other  {{organizer_name} has invited you to their party}
    }

# Keep select on the outside and plural inside it. offset:1 subtracts one
# from the number before choosing a branch and before # is printed, which
# is how "and 3 other people" counts correctly.
party_guests: >-
    {num_guests, plural, offset:1
        =0    {{host} is not having a party}
        =1    {{host} invites {guest} to their party}
        other {{host} invites {guest} and # other people to their party}
    }

Numbers, percentages and ordinals inside a message

Reach for these when a number is part of a sentence a translator owns. The ordinal case is the clearest win: 23rd in English is 23e in French and 23. in German, and no amount of PHP string work gets that right from outside the catalogue. Note that number, currency uses the locale's default currency, so a real price is better formatted in the template where the currency code is known.

Example · yaml
# translations/messages+intl-icu.en.yaml

# The second word is the function, the third its style. These use the same
# locale data as the Intl formatters, so the grouping and the decimal mark
# follow the locale of the catalogue being read.
progress:        '{progress, number, percent} of the work is done'
value_of_object: 'This item is worth {value, number, currency}'
finish_place:    'You finished {place, ordinal}'
published_at:    'Published {publication_date, date, medium}'

# $translator->trans('progress', ['progress' => 0.82]);
# Output: 82% of the work is done
#
# $translator->trans('finish_place', ['place' => 23]);
# Output: You finished 23rd

Passing parameters from Twig

The two naming styles are not interchangeable, and the filter will not warn you: a missing placeholder simply leaves the marker in the output. When a project is migrating, the quickest check is the filename of the domain the id lives in. The last line is the normal case for user-supplied values, and it is safe precisely because it uses the filter rather than the trans tag.

Example · twig
{# templates/cart/summary.html.twig #}

{# ICU domain: bare names. #}
<p>{{ 'cart.items'|trans({'count': cart.itemCount}) }}</p>

{# Plain domain: percent-wrapped names. A numeric %count% is what makes
   the pipe-separated plural form kick in. #}
<p>{{ 'cart.items'|trans({'%count%': cart.itemCount}, 'legacy') }}</p>

{# Placeholders are substituted after the lookup, and the filter's output
   is escaped, so a name containing < or & is safe here. #}
<p>{{ 'greeting.hello'|trans({'%name%': app.user.displayName}) }}</p>

Discussion

  • Be the first to comment on this lesson.