Dates, Numbers and Currency

Locale-aware dates and numbers, UTC storage with local rendering, and formatting money without losing precision.

Syntax{{ 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

Example · php
<?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,900

When 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.

Example · twig
{# 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.

Example · php
<?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:30

Defaults, 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.

Example · yaml
# 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

  • Be the first to comment on this lesson.