ICU Message Formatting Guide

derafu/translation doesn’t implement its own message formatter. ICU formatting comes directly from PHP’s intl extension (\MessageFormatter), used in two places:

  1. TranslatableMessage::__toString() — formats the raw message eagerly, without a translator. This is what makes getMessage() on a translatable exception return a fully formatted string with zero setup.
  2. symfony/translation’s own ICU formatter — used when translating through a real Translator, for any domain whose name ends in +intl-icu (see Resource Providers and the API Reference).

Both paths accept the exact same ICU MessageFormat syntax described below.

A message with no entry in the translator’s catalogues is formatted by the first path: TranslatableMessage::trans() returns the same text as __toString() (and so as getMessage()) when the translator knows its catalogues (TranslatorBagInterface, like Symfony’s) and has no entry for the message, in its locale or in the fallback ones, within the message’s domain. symfony/translation does not apply ICU to ids it does not have: it would replace the name of each parameter inside the braces (Hello {John}).

Basic Placeholders

The simplest form uses named placeholders:

throw new ValidationException(
    'The field {field} is required.',
    ['field' => 'email'],
);
// getMessage(): "The field email is required."

Multiple placeholders are supported:

throw new ValidationException(
    'Value {value} for field {field} is invalid.',
    ['value' => 'test@', 'field' => 'email'],
);
// getMessage(): "Value test@ for field email is invalid."

Pluralization

$id = '{count, plural, =0{No messages} one{# message} other{# messages}}';

new TranslatableMessage($id, ['count' => 2]); // "2 messages"
new TranslatableMessage($id, ['count' => 1]); // "1 message"
new TranslatableMessage($id, ['count' => 0]); // "No messages"

Available plural categories:

  • zero: for languages with a special zero form.
  • one: singular form.
  • two: dual form (for languages that have it).
  • few / many: for languages with special handling of small/large numbers.
  • other: default form (required — always provide it).
  • =n: exact number matches (e.g. =0).

Gender / Select

$id = '{gender, select, female {She liked your post} male {He liked your post} other {They liked your post}}';

(string) new TranslatableMessage($id, ['gender' => 'female']);
// "She liked your post"

Nested placeholders work too:

$id = '{gender, select, female {{name} added her comment} male {{name} added his comment} other {{name} added their comment}}';

(string) new TranslatableMessage($id, ['gender' => 'female', 'name' => 'Alice']);
// "Alice added her comment"

Number Formatting

'{value, number}'          // Plain number.
'{value, number, percent}' // Percentage.
'{value, number, currency}' // Currency, using the message's own locale.

Currency formatting depends on the locale carrying a specific currency association (e.g. en_US, not just en):

new TranslatableMessage(
    'Balance must be greater than {min, number, currency}.',
    ['min' => 100],
    null,
    'en_US',
);
// "Balance must be greater than $100.00."

Nested Plural + Select

Patterns can be nested for combined scenarios:

$id = '{gender, select,
    female {{count, plural, =0{She has no messages} one{She has # message} other{She has # messages}}}
    male {{count, plural, =0{He has no messages} one{He has # message} other{He has # messages}}}
    other {{count, plural, =0{They have no messages} one{They have # message} other{They have # messages}}}
}';

(string) new TranslatableMessage($id, ['gender' => 'female', 'count' => 5]);
// "She has 5 messages"

Common Patterns

// Range validation.
'Value must be between {min} and {max}.'

// List validation.
'{count, plural, =0{List cannot be empty} one{At least one item is required} other{At least # items are required}}'

// Status messages.
'{status, select, pending{Waiting for approval} approved{Approved on {date}} rejected{Rejected: {reason}} other{Unknown status}}'

Troubleshooting

  1. Missing the other category — required for every select/plural pattern. Without it, a value that doesn’t match any listed category falls back to the raw, unformatted message (same as an invalid pattern, see below):

    // Wrong.
    '{gender, select, male{He} female{She}}'
    
    // Correct.
    '{gender, select, male{He} female{She} other{They}}'
    
  2. Unmatched braces:

    // Wrong.
    'Hello {name'
    
    // Correct.
    'Hello {name}'
    

    TranslatableMessage::__toString() catches this: an invalid ICU pattern returns the raw message unchanged rather than throwing.

  3. Missing parameters don’t throw either — the placeholder is simply left unexpanded:

    (string) new TranslatableMessage('{count} items'); // "{count} items"
    

For more on ICU MessageFormat syntax:

On this page

Last updated on 30/10/2026 by Anonymous