[Intl] Add format_list filter using PHP 8.5's IntlListFormatter

This commit is contained in:
Marvin Feldmann
2026-07-01 16:29:17 +02:00
committed by Fabien Potencier
parent d1abdf483a
commit 163f1b6341
8 changed files with 224 additions and 2 deletions
+4 -1
View File
@@ -23,6 +23,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'
steps:
- name: "Checkout code"
@@ -68,6 +69,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'
steps:
- name: "Checkout code"
@@ -119,6 +121,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'
extension:
- 'cache-extra'
- 'cssinliner-extra'
@@ -183,7 +186,7 @@ jobs:
strategy:
matrix:
php-version:
- '8.4'
- '8.5'
steps:
- name: "Checkout code"
+4
View File
@@ -1,3 +1,7 @@
# 3.29.0 (2026-XX-XX)
* Add a `format_list` filter to `IntlExtension` to format a list of strings using PHP 8.5's `IntlListFormatter`
# 3.28.1 (2026-XX-XX)
* Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError`
+87
View File
@@ -0,0 +1,87 @@
``format_list``
===============
.. versionadded:: 3.29
The ``format_list`` filter was added in Twig 3.29.
.. note::
The ``format_list`` filter requires the ``IntlListFormatter`` class, which is available since PHP 8.5.
The ``format_list`` filter formats a list of strings as a single, locale-aware string:
.. code-block:: twig
{# en: Apple, Banana, and Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list }}
The list of supported types:
* ``and``: Formats the list as a conjunction (default)::
{# en: Apple, Banana, and Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'and', locale: 'en') }}
* ``or``: Formats the list as a disjunction::
{# en: Apple, Banana, or Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'or', locale: 'en') }}
* ``units``: Formats the list as a compound unit, without implying a conjunction or disjunction::
{# en: Apple, Banana, Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(type: 'units', locale: 'en') }}
The list of supported widths:
* ``wide``: The full-length form (default)::
{# en: Apple, Banana, and Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'wide', locale: 'en') }}
* ``short``: A shorter form::
{# en: Apple, Banana, & Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'short', locale: 'en') }}
* ``narrow``: The most compact form::
{# en: Apple, Banana, Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(width: 'narrow', locale: 'en') }}
By default, the filter uses the current locale. You can pass it explicitly::
{# fr: Apple, Banana et Cherry #}
{{ ['Apple', 'Banana', 'Cherry']|format_list(locale: 'fr') }}
.. note::
The ``format_list`` filter is part of the ``IntlExtension`` which is not installed by default. Install it first:
.. code-block:: sh
$ composer require twig/intl-extra
Then, on Symfony projects, install the ``twig/extra-bundle``:
.. code-block:: sh
$ composer require twig/extra-bundle
Otherwise, add the extension explicitly on the Twig environment::
use Twig\Extra\Intl\IntlExtension;
$twig = new \Twig\Environment(...);
$twig->addExtension(new IntlExtension());
Arguments
---------
* ``type``: The type of the list output
* ``width``: The width of the list output
* ``locale``: The locale code as defined in `RFC 5646`_
.. _RFC 5646: https://www.rfc-editor.org/info/rfc5646
+1
View File
@@ -24,6 +24,7 @@ Filters
format_currency
format_date
format_datetime
format_list
format_number
format_time
html_attr_merge
+81
View File
@@ -56,6 +56,40 @@ final class IntlExtension extends AbstractExtension
return $formats;
}
private static function availableListTypes(): array
{
static $types = null;
if (null !== $types) {
return $types;
}
$types = [
'and' => \IntlListFormatter::TYPE_AND,
'or' => \IntlListFormatter::TYPE_OR,
'units' => \IntlListFormatter::TYPE_UNITS,
];
return $types;
}
private static function availableListWidths(): array
{
static $widths = null;
if (null !== $widths) {
return $widths;
}
$widths = [
'wide' => \IntlListFormatter::WIDTH_WIDE,
'short' => \IntlListFormatter::WIDTH_SHORT,
'narrow' => \IntlListFormatter::WIDTH_NARROW,
];
return $widths;
}
private const TIME_FORMATS = [
'none' => \IntlDateFormatter::NONE,
'short' => \IntlDateFormatter::SHORT,
@@ -149,6 +183,7 @@ final class IntlExtension extends AbstractExtension
private $dateFormatters = [];
private $numberFormatters = [];
private $listFormatters = [];
private $dateFormatterPrototype;
private $numberFormatterPrototype;
@@ -176,6 +211,7 @@ final class IntlExtension extends AbstractExtension
new TwigFilter('format_datetime', [$this, 'formatDateTime'], ['needs_environment' => true]),
new TwigFilter('format_date', [$this, 'formatDate'], ['needs_environment' => true]),
new TwigFilter('format_time', [$this, 'formatTime'], ['needs_environment' => true]),
new TwigFilter('format_list', [$this, 'formatList']),
];
}
@@ -406,6 +442,18 @@ final class IntlExtension extends AbstractExtension
return $this->formatDateTime($env, $date, 'none', $timeFormat, $pattern, $timezone, $calendar, $locale);
}
/**
* @param array<string|\Stringable> $strings A list of items to be joined into a formatted list
*/
public function formatList(array $strings, string $type = 'and', string $width = 'wide', ?string $locale = null): string
{
if (!class_exists('IntlListFormatter')) {
throw new RuntimeError('The "format_list" filter requires the "IntlListFormatter" class, which is available since PHP 8.5.');
}
return $this->createListFormatter($locale, $type, $width)->format($strings);
}
private function createDateFormatter(?string $locale, ?string $dateFormat, ?string $timeFormat, string $pattern, ?\DateTimeZone $timezone, string $calendar): \IntlDateFormatter
{
$dateFormats = self::availableDateFormats();
@@ -530,4 +578,37 @@ final class IntlExtension extends AbstractExtension
return $this->numberFormatters[$hash];
}
private function createListFormatter(?string $locale, string $type, string $width): \IntlListFormatter
{
$listTypes = self::availableListTypes();
if (!isset($listTypes[$type])) {
throw new RuntimeError(\sprintf('The list type "%s" does not exist, known types are: "%s".', $type, implode('", "', array_keys($listTypes))));
}
$listWidths = self::availableListWidths();
if (!isset($listWidths[$width])) {
throw new RuntimeError(\sprintf('The list width "%s" does not exist, known widths are: "%s".', $width, implode('", "', array_keys($listWidths))));
}
if (null === $locale) {
$locale = \Locale::getDefault();
}
$listTypeValue = $listTypes[$type];
$listWidthValue = $listWidths[$width];
$hash = $locale.'|'.$listTypeValue.'|'.$listWidthValue;
if (!isset($this->listFormatters[$hash])) {
if (\count($this->listFormatters) >= self::MAX_CACHED_FORMATTERS) {
array_shift($this->listFormatters);
}
$this->listFormatters[$hash] = new \IntlListFormatter($locale, $listTypeValue, $listWidthValue);
}
return $this->listFormatters[$hash];
}
}
+3 -1
View File
@@ -14,7 +14,8 @@ This package is a Twig extension that provides the following:
* [`format_number`][9] filter: formats a number;
* [`format_datetime`][10] filter: formats a date time;
* [`format_date`][11] filter: formats a date;
* [`format_time`][12] filter: formats a time.
* [`format_time`][12] filter: formats a time;
* [`format_list`][13] filter: formats a list of strings as a single, locale-aware string.
[1]: https://twig.symfony.com/country_name
[2]: https://twig.symfony.com/currency_name
@@ -28,3 +29,4 @@ This package is a Twig extension that provides the following:
[10]: https://twig.symfony.com/format_datetime
[11]: https://twig.symfony.com/format_date
[12]: https://twig.symfony.com/format_time
[13]: https://twig.symfony.com/format_list
@@ -0,0 +1,10 @@
--TEST--
"format_list" filter
--CONDITION--
PHP_VERSION_ID < 80500
--TEMPLATE--
{{ ['Alice', 'Bob', 'Carol']|format_list() }}
--DATA--
return []
--EXCEPTION--
Twig\Error\RuntimeError: The "format_list" filter requires the "IntlListFormatter" class, which is available since PHP 8.5 in "index.twig" at line 2.
@@ -0,0 +1,34 @@
--TEST--
"format_list" filter
--CONDITION--
PHP_VERSION_ID >= 80500
--TEMPLATE--
{{ ['Alice', 'Bob', 'Carol']|format_list() }}
{{ ['Alice', 'Bob', 'Carol']|format_list(width='short')|raw }}
{{ ['Alice', 'Bob', 'Carol']|format_list('or') }}
{{ ['Alice', 'Bob', 'Carol']|format_list(locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('and', 'short', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('and', 'narrow', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'wide', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'short', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('or', 'narrow', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'wide', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'short', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'narrow', locale='fr') }}
{{ ['Alice', 'Bob', 'Carol']|format_list('units', 'narrow', locale='de') }}
--DATA--
return [];
--EXPECT--
Alice, Bob, and Carol
Alice, Bob, & Carol
Alice, Bob, or Carol
Alice, Bob et Carol
Alice, Bob et Carol
Alice, Bob, Carol
Alice, Bob ou Carol
Alice, Bob ou Carol
Alice, Bob ou Carol
Alice, Bob et Carol
Alice, Bob et Carol
Alice Bob Carol
Alice, Bob und Carol