senseexception/intl-format
Locale-aware formatting for Laravel/PHP apps using PHP’s Intl extension. senseexception/intl-format simplifies displaying numbers, currencies, percentages, dates, and times with consistent localized output and minimal configuration.
1. Install and verify the environment
composer require senseexception/intl-format
php -m | grep intl # must output "intl"
The package is a thin wrapper over PHP's intl extension (NumberFormatter, IntlDateFormatter, MessageFormatter). If ext-intl is not compiled in, you get a hard fatal error the first time you format anything — check this before wiring it into a service container.
2. Where to look first
README.md usage block — it is short, so read it once and then read the tests.src/ directory: identify the main message formatter class and any value-formatter classes it delegates to (number, currency, date).tests/) — with a package this small, the tests are the most reliable specification of the placeholder syntax and constructor signatures.3. First use case: localized values inside a human message
The canonical day-one use is a message that mixes prose with a locale-aware number or date, which sprintf cannot do correctly across locales:
<?php
require 'vendor/autoload.php';
use SenseException\IntlFormat\MessageFormatter;
$formatter = new MessageFormatter('de_DE');
echo $formatter->format(
'Ihr Gesamtbetrag beträgt {0, number, currency}.',
[1234.5]
);
// Ihr Gesamtbetrag beträgt 1.234,50 €.
Note the locale does the work: the same call with en_US produces 1,234.50 $. Do not hand-roll number_format() in templates — that is exactly what this package replaces.
Wrap the formatter as a service, not a static helper
Inject the formatter (or a factory that produces one per locale) so the locale is resolved per request rather than hard-coded:
final class PriceMessage
{
public function __construct(private MessageFormatter $formatter) {}
public function render(float $amount): string
{
return $this->formatter->format(
'Total: {0, number, currency}',
[$amount]
);
}
}
Resolve locale from user context, cache per locale
Create one formatter per locale and reuse it; constructing NumberFormatter/IntlDateFormatter instances is comparatively expensive.
$formatters = [];
$formatter = $formatters[$locale] ??= new MessageFormatter($locale);
Pull the locale from the authenticated user's preference, falling back to the request's accepted languages, then to a configured default.
Symfony integration
Register the formatter as a service and, if the package exposes an IntlFormatterInterface implementation, tag it so the translator uses it:
services:
SenseException\IntlFormat\MessageFormatter:
arguments: ['%kernel.default_locale%']
Compose with translation, not against it
Order matters. Either translate the message template first and then format the placeholders, or keep message templates locale-neutral and let the formatter own the value rendering. Do not run sprintf and ICU formatting on the same string — pick one layer.
Custom/domain formatters
If the package allows registering named value formatters (check the extension points in src/), add domain-specific ones for things like file sizes or durations and keep the call sites declarative:
$formatter->format('Upload: {0, fileSize}', [$bytes]);
Testing pattern
Pin the locale explicitly in each test and use a data provider for the locales you actually ship:
/**
* @dataProvider locales
*/
public function testCurrency(string $locale, string $expected): void
{
$f = new MessageFormatter($locale);
self::assertSame($expected, $f->format('{0, number, currency}', [1234.5]));
}
ICU quoting will bite you. In ICU message syntax an apostrophe starts a literal-quoted section. Text like L'utilisateur can silently swallow placeholders unless you double it: L''utilisateur. When in doubt, quote the whole prose segment or escape every literal apostrophe.
Percent values are ratios, not percentages. Formatting 0.15 with percent yields 15%. If you already multiplied by 100, you will render 1500%. Decide once, in the calling code, whether inputs are fractions or percentages.
Currency needs both a locale and a currency code. The locale controls placement and symbol; the currency determines the code. Passing a number without setting the currency can fall back to the locale's default, so be explicit if you format multiple currencies.
Dates need a timezone. IntlDateFormatter defaults to UTC (or the system timezone depending on PHP version). Always pass the user's or application's timezone explicitly, or you will produce off-by-hours dates in production that never appear in UTC-based tests.
Invalid locales and patterns fail quietly. intl formatters frequently return false and set an error code rather than throwing. Check the error state after construction and after formatting, and log the raw message plus locale — this is the fastest way to debug a broken placeholder.
Version drift across environments. ICU data differs between OS packages and PHP builds. A pattern that parses on your laptop can behave differently in CI or on Alpine. Test in the same base image you deploy, or accept small differences and assert on structure rather than exact strings.
Long-running workers (Swoole, RoadRunner, Octane). Formatter instances carry locale and pattern state. If you reuse a singleton across requests with different locales, one request's locale leaks into the next. Recreate per request or key the cache by locale and reset it at request boundaries.
Rounding and precision. The default NumberFormatter rounding is not the same as round(). If monetary amounts must match an accounting rule, set precision/rounding attributes explicitly (or format from integer minor units) instead of trusting defaults.
Literal braces in prose. A message that legitimately contains { or } must escape them, or the pattern parser treats them as placeholder boundaries and errors out.
Prefer the package's extension points over subclassing internals. With a 12-star package, the internals are not a stable contract. If you need new behavior, register a value formatter or wrap the call site; do not extend private classes, or upgrades will break you.
Read the tests before the README. Documentation is minimal; the test files enumerate supported placeholder types, constructor arguments, and edge cases far more completely than the README does.
How can I help you explore Laravel packages today?