Dates, Numbers and Currency
Locale-aware dates and numbers, UTC storage with local rendering, and formatting money without losing precision.
{{ amount|format_currency('EUR', {fraction_digit: 2}, locale: 'de') }}Formatting is a display decision, separate from the value. The number 1234.5 prints as 1,234.5 in en, 1.234,5 in de and 1 234,5 in fr. PHP's intl extension carries the CLDR locale data that decides this; symfony/intl exposes its reference lists, and twig/intl-extra exposes its formatters as Twig filters.
Dates
Store an instant, render a rendering. Keep every stored datetime in UTC, and convert at the edge: format_datetime takes both a timezone and a locale. Twig's core date filter is not locale-aware — it uses PHP format characters and never translates a month name — so it belongs in a machine-readable datetime attribute, not in prose.
Numbers and the shorthand
format_number takes an attribute map and a style. The format_*_number filters are the same call with the style filled in:
{{ 0.182|format_percent_number({fraction_digit: 1}) }}
{{ 0.182|format_number({fraction_digit: 1}, 'percent') }}Money
Hold money as an integer count of minor units — 1999 for 19.99 euro — and divide only when formatting, by ten to the power of Currencies::getFractionDigits(). Two mistakes follow from skipping this. Floats accumulate rounding error, so a sum of prices drifts. And the number of decimals is a property of the currency, not a constant two.
What trips people up
\NumberFormatter and \IntlDateFormatter come from the intl extension. Without it enabled, these examples and the Intl Twig filters fail outright.
Example
<?php
// src/Money/MoneyFormatter.php
declare(strict_types=1);
namespace App\Money;
use Symfony\Component\Intl\Currencies;
final class MoneyFormatter
{
/**
* @param int $minorUnits amount in the currency's smallest unit:
* 199900 is 1999.00 EUR, 199900 is 199900 JPY
* @param string $currency ISO 4217 code, e.g. 'USD'
*/
public function format(int $minorUnits, string $currency, string $locale): string
{
// 2 for USD and EUR, 0 for JPY, 3 for KWD.
$digits = Currencies::getFractionDigits($currency);
$formatter = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
// Without this, a JPY amount is printed with two decimal places.
$formatter->setAttribute(\NumberFormatter::FRACTION_DIGITS, $digits);
// The only division, at the display edge. The stored value, and any
// arithmetic done on it, stays an integer.
$amount = $minorUnits / 10 ** $digits;
$formatted = $formatter->formatCurrency($amount, $currency);
if (false === $formatted) {
throw new \RuntimeException($formatter->getErrorMessage());
}
return $formatted;
}
}
// $f = new MoneyFormatter();
//
// $f->format(199900, 'USD', 'en_US'); // Output: $1,999.00
// $f->format(199900, 'JPY', 'en_US'); // Output: ¥199,900When to use it
- Show an order timestamp in each customer's own timezone from one UTC column.
- Print prices in euro, yen and dinar from the same integer column without hardcoding decimal places.
- Render a German invoice where 1.234,56 is correct and 1,234.56 is a bug.
- Emit both a machine-readable datetime attribute and a human-readable date from one value.
More examples
Dates and numbers in a template
Reach for these filters whenever a value is being shown to a person. The four style names - short, medium, long, full - are the whole vocabulary, and they mean different things per locale, which is the point: full is 'Sonntag, 1. März 2026' in German without the template knowing anything about German. The <time> element is the pattern worth copying: one machine-readable value in the attribute, one localised value for the reader.
{# templates/order/show.html.twig #}
{# order.placedAt is a \DateTimeImmutable held in UTC. format_datetime
converts and translates: the arguments are the date style, the time
style, then named pattern/timezone/calendar/locale. #}
<p>{{ order.placedAt|format_datetime('medium', 'short', timezone: 'Europe/Madrid') }}</p>
{# Date only, in a fixed locale rather than the request's. #}
<p>{{ order.placedAt|format_date('full', locale: 'de') }}</p>
{# The core date filter: PHP format characters, no translation. Right for
a datetime attribute, wrong for anything a reader is meant to read. #}
<time datetime="{{ order.placedAt|date('c') }}">{{ order.placedAt|format_date('short') }}</time>
{# Money, formatted from a major-unit value and an explicit currency. #}
<p>{{ order.totalAsDecimalString|format_currency(order.currency) }}</p>
{# These two produce the same string. #}
<p>{{ order.taxRate|format_percent_number({fraction_digit: 1}) }}</p>
<p>{{ order.taxRate|format_number({fraction_digit: 1}, 'percent') }}</p>UTC in, local out, in plain PHP
The same job as format_datetime, for code that has no template: a CSV export, a notification body, a console report. The timezone argument is doing the real work - the stored value is untouched, and the shift from 23:30 UTC to 08:30 the next day is the formatter's. Pass the timezone explicitly rather than relying on the process default, because a worker and a web request can easily be configured differently.
<?php
// src/Time/LocalTimeFormatter.php
declare(strict_types=1);
namespace App\Time;
final class LocalTimeFormatter
{
/**
* @param \DateTimeImmutable $instant a UTC value, as stored
* @param non-empty-string $timeZone an IANA id, e.g. 'Asia/Tokyo'
*/
public function render(\DateTimeImmutable $instant, string $timeZone, string $locale): string
{
$formatter = new \IntlDateFormatter(
$locale,
\IntlDateFormatter::MEDIUM, // date part
\IntlDateFormatter::SHORT, // time part
new \DateTimeZone($timeZone), // the conversion happens here
);
$formatted = $formatter->format($instant);
if (false === $formatted) {
throw new \RuntimeException($formatter->getErrorMessage());
}
return $formatted;
}
}
// $instant = new \DateTimeImmutable('2026-03-01 23:30:00', new \DateTimeZone('UTC'));
//
// (new LocalTimeFormatter())->render($instant, 'Asia/Tokyo', 'en_GB');
// Output: 2 Mar 2026, 08:30Defaults, so every template does not repeat itself
Set these once at the start of a project. The pairing that matters is date.timezone = UTC in PHP with twig.date.timezone: UTC: together they guarantee that a date which skipped the locale-aware filters shows a UTC time, which a reviewer notices, instead of a server-local time that happens to look plausible. Note that twig.number_format configures only the core number_format filter; it has no effect on format_number or format_currency.
# config/packages/twig.yaml
twig:
date:
# Default timezone for the core `date` filter. Pinning it to UTC
# means an unconverted date is obviously wrong, not subtly wrong.
timezone: 'UTC'
format: 'Y-m-d H:i'
interval_format: '%d days'
number_format:
# Defaults for the core `number_format` filter only. These are
# fixed strings, not locale data - format_number is the
# locale-aware filter.
decimals: 2
decimal_point: '.'
thousands_separator: ','
# config/packages/framework.yaml
framework:
# The locale the Intl filters use when none is passed.
default_locale: 'en'
# php.ini, or a Docker image's ini fragment:
# date.timezone = UTC
# Everything PHP does with an unqualified date then happens in UTC,
# which is what makes 'store UTC' true rather than aspirational.
Discussion