symfony/polyfill-intl-messageformatter
Symfony polyfill that supplies the IntlMessageFormatter class when PHP's intl extension is unavailable, enabling ICU message formatting and localization in apps on minimal or older PHP setups. A drop-in fallback for consistent message parsing.
This is a polyfill, not a framework component. You install it so that \MessageFormatter exists on hosts where the intl extension is missing (common on minimal Docker images, shared hosting, and some CI runners). There is no service provider, config file, or facades.
composer require symfony/polyfill-intl-messageformatter:^1.43
Pin ^1.43 (not the older ^1.10) if your messages use selectordinal, list patterns, or {n, number} rounding — see the release note at the bottom of this section.
Composer's files autoload runs bootstrap.php on every request. It defines the global MessageFormatter class only if it doesn't already exist, so it is a no-op wherever native intl is loaded. Check what you actually got:
php -m | grep intl # is native intl present?
php -r "var_dump(class_exists('MessageFormatter'));"
php -r "(new ReflectionClass('MessageFormatter'))->getFileName();" # polyfill path vs. "ext-intl"
First use case — ICU plural/select messages without intl:
$mf = \MessageFormatter::create('en', '{name} invited {count, plural, one {# guest} other {# guests}}');
echo $mf->format(['name' => 'Ana', 'count' => 3]); // "Ana invited 3 guests"
Since v1.43.0 the same instance-level API also covers ordinal selection, which used to be a hard gap:
$mf = \MessageFormatter::create('en', 'You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}');
echo $mf->format(['place' => 3]); // "You finished 3rd"
Look first at the polyfill's MessageFormatter.php for the supported ICU subset (it is still explicitly partial, just larger than it was), and at the README caveats. Then run the same snippet on a machine with ext-intl and diff the output — that comparison is the real documentation.
Upgrading from ≤1.38: v1.43.0 is a bugfix + one feature release, no BC breaks, but three of the fixes change rendered output: integer rounding in {n, number}, English list patterns ({0}, {1}, and {2}), and multibyte substring handling in patterns. If you snapshot-test formatted strings, expect to rebaseline a few of them.
Wrap it behind your own interface. You never want \MessageFormatter scattered through domain code, because behavior differs between native intl and the polyfill.
final class IcuFormatter
{
/** @var array<string, \MessageFormatter> */
private array $cache = [];
public function format(string $locale, string $pattern, array $args): string
{
$key = $locale."\0".$pattern;
$mf = $this->cache[$key] ??= \MessageFormatter::create($locale, $pattern);
if ($mf === false || $mf === null) {
throw new \RuntimeException("Invalid ICU pattern for locale {$locale}");
}
$out = $mf->format($args);
if ($out === false) {
throw new \RuntimeException(sprintf(
'ICU format failed (%d): %s',
$mf->getErrorCode(),
$mf->getErrorMessage()
));
}
return $out;
}
}
Symfony Translation integration. If you use messages+intl-icu.en.yaml (or any ICU-message catalog), Symfony's translator delegates to \MessageFormatter. On a host without intl, installing this polyfill is what makes those catalogs work at all. No config change needed — just confirm the polyfill is actually the class being used via ReflectionClass, and keep the polyfill in require (not require-dev) so production images get it.
Plural / select patterns. These are the 90% use case:
\MessageFormatter::create('en', <<<'ICU'
{gender, select,
female {{name} updated her profile}
male {{name} updated his profile}
other {{name} updated their profile}
}
ICU)->format(['gender' => 'female', 'name' => 'Sam']);
Ordinal ranks (new in 1.43). Leaderboards, "nth attempt", "nth invoice" strings can now be authored as plain ICU instead of a PHP match:
\MessageFormatter::create('en', '{rank, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place')
->format(['rank' => 22]); // "22nd place"
Remember selectordinal follows CLDR plural categories, so locales that don't distinguish ordinals (e.g. de, fr) fall through to other — don't assume one/two/few arms fire for every locale.
List patterns. The English list fixes in 1.43 mean {0}, {1}, and {2}-style messages now render with correct separators and conjunction. If you had hand-rolled implode() because the polyfill produced A, B, C (no "and"), you can move those back into ICU — but only after pinning ^1.43, since older versions silently drop the conjunction.
Locale-per-request, cache-per-pattern. MessageFormatter objects are mutable (setPattern, setFormats). Create one per locale+pattern pair and reuse; do not share a single instance across locales or requests.
Test both paths in CI. Use a matrix with ext-intl enabled and disabled (shivammathur/setup-php with extensions: none vs intl, or two Docker images). Any test that only runs with native intl will hide polyfill gaps, and vice versa. Add explicit parity cases for selectordinal, list patterns, {n, number} rounding, and multibyte (accented/CJK) literals — those are exactly the surfaces 1.43 touched.
Static analysis. The polyfill ships the real class file in vendor/, so PHPStan/Psalm/IDEs see MessageFormatter even when intl is loaded. Guard runtime usage when ext-intl is optional:
if (!class_exists(\MessageFormatter::class)) {
// neither intl nor the polyfill is installed
}
Static one-shot helper. MessageFormatter::formatMessage($locale, $pattern, $args) is convenient for logs/report lines, but it parses on every call — cache real instances for hot paths.
plural, select, selectordinal, list patterns, and {n, number} rounding parity. Still verify before betting on it: date, time, spellout, duration, :: skeletons, offset: in plurals, apostrophe-quoting edge cases, # inside nested plurals, and {0} positional arguments. Number and date formatting in particular often require NumberFormatter/IntlDateFormatter, which may pull in other polyfills or fail without intl.iconv_substr()/iconv_strrpos() fixes mean patterns containing non-ASCII literals (accented names, CJK, emoji) could previously be truncated or mis-parsed mid-character. If you carried a workaround like "keep patterns ASCII, translate after formatting", retest on ^1.43 before keeping it.class_exists('MessageFormatter') cannot distinguish native from polyfill. Use extension_loaded('intl') for that. The autoload bootstrap is silent about which path it took.class_exists(..., false); if an IDE-helper stub or another polyfill loaded MessageFormatter earlier, this one does nothing and you may get a broken stub. Inspect (new ReflectionClass('MessageFormatter'))->getFileName() when debugging weirdness.create() and format() return false on failure. With declare(strict_types=1) you'll still get a falsy value, not a TypeError. Always check and surface getErrorCode() / getErrorMessage(). Error codes and messages can differ between native intl and the polyfill, so don't assert on exact strings in tests.symfony/polyfill-intl-grapheme, -normalizer, and -collator (via the symfony/polyfill-intl-* family). If you depend on more than one of them, upgrade them as a set — a mixed-version install is a classic source of "works locally, wrong on the CI image" sorting/multibyte bugs.intl. Test with your real target locales, not just en — selectordinal in particular is only meaningful in a handful of locales.ext-intl. Opcache/preload can hold the polyfill's class definition even after you install the extension, or vice versa. composer dump-autoload -o and a worker restart avoid "why is it still slow/broken" confusion.format() parses the pattern in userland PHP; native intl caches compiled patterns internally. Cache formatter instances per locale+pattern and avoid constructing them inside loops. Avoid user-supplied patterns — nesting can be expensive, and the polyfill amplifies that.composer why symfony/polyfill-intl-messageformatter — it is often pulled transitively alongside symfony/translation or symfony/validator. That transitive constraint may still resolve to ≤1.38 even after you think you "upgraded"; run composer show symfony/polyfill-intl-messageformatter and confirm versions reads 1.43+.ext-intl. If the host can compile extensions, install intl (it wins automatically) and keep the polyfill as a safety net for environments that can't. Use the polyfill for portability, not for parity.How can I help you explore Laravel packages today?