Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'

steps:
- name: "Checkout code"
Expand Down Expand Up @@ -68,6 +69,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'

steps:
- name: "Checkout code"
Expand Down Expand Up @@ -119,6 +121,7 @@ jobs:
- '8.2'
- '8.3'
- '8.4'
- '8.5'
extension:
- 'cache-extra'
- 'cssinliner-extra'
Expand Down Expand Up @@ -183,7 +186,7 @@ jobs:
strategy:
matrix:
php-version:
- '8.4'
- '8.5'

steps:
- name: "Checkout code"
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# 3.28.1 (2026-XX-XX)

* Add a `format_list` filter to `IntlExtension` to format a list of strings using PHP 8.5's `IntlListFormatter`
* Fix duplicated macro argument names triggering a PHP fatal error instead of a `SyntaxError`

# 3.28.0 (2026-07-03)
Expand Down
81 changes: 81 additions & 0 deletions doc/filters/format_list.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
``format_list``
================

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());

The ``format_list`` filter also requires the ``IntlListFormatter`` class,
which is available since PHP 8.5.

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 change: 1 addition & 0 deletions doc/filters/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Filters
format_currency
format_date
format_datetime
format_list
format_number
format_time
html_attr_merge
Expand Down
81 changes: 81 additions & 0 deletions extra/intl-extra/IntlExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,40 @@ private static function availableDateFormats(): array
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,
Expand Down Expand Up @@ -149,6 +183,7 @@ private static function availableDateFormats(): array

private $dateFormatters = [];
private $numberFormatters = [];
private $listFormatters = [];
private $dateFormatterPrototype;
private $numberFormatterPrototype;

Expand Down Expand Up @@ -176,6 +211,7 @@ public function getFilters(): array
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']),
];
}

Expand Down Expand Up @@ -406,6 +442,18 @@ public function formatTime(Environment $env, $date, ?string $timeFormat = 'mediu
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();
Expand Down Expand Up @@ -530,4 +578,37 @@ private function createNumberFormatter(?string $locale, string $style, array $at

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];
}
}
10 changes: 10 additions & 0 deletions extra/intl-extra/Tests/Fixtures/format_list.test
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 34 additions & 0 deletions extra/intl-extra/Tests/Fixtures/format_list_php85.test
Original file line number Diff line number Diff line change
@@ -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