DEV Community

Cover image for PHP 8.5 IntlListFormatter: Localized 'A, B, and C' Without the Helper
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.5 IntlListFormatter: Localized 'A, B, and C' Without the Helper


Open the helpers.php in almost any PHP project and you'll find a function that turns ['a', 'b', 'c'] into "a, b, and c". It pops the last element, glues the rest with commas, and staples an and in front of the tail. It works. It also hardcodes English, hardcodes your opinion on the Oxford comma, and produces nonsense the moment someone sets their locale to German or Spanish.

PHP 8.5 shipped IntlListFormatter in November 2025. It's a thin wrapper over ICU's list formatting, and it does the one job that helper was faking: turning an array into a grammatical, locale-correct list. Here's what it replaces and where the sharp edges are.

The hack everyone ships

You've written this, or inherited it:

function humanList(array $items): string
{
    if (count($items) === 0) {
        return '';
    }
    if (count($items) === 1) {
        return $items[0];
    }
    $last = array_pop($items);
    return implode(', ', $items) . ' and ' . $last;
}

humanList(['Lisbon', 'Porto', 'Coimbra']);
// "Lisbon, Porto, and Coimbra"
Enter fullscreen mode Exit fullscreen mode

Three problems hide in those seven lines. The word and is baked in, so an "or" list needs a second copy of the function. The comma-before-and is a choice German and Spanish don't make. And two-item lists in English drop the comma ("Lisbon and Porto"), so now you're special-casing counts. Every one of those rules already lives in ICU. You're reimplementing a locale database by hand.

What PHP 8.5 gives you

IntlListFormatter is a final class in the global namespace, sitting next to IntlDateFormatter and NumberFormatter. You give it a locale, ask it to format() an array, and you get the string back.

$fmt = new IntlListFormatter('en');

echo $fmt->format(['Lisbon', 'Porto', 'Coimbra']);
// Lisbon, Porto, and Coimbra

echo $fmt->format(['Lisbon', 'Porto']);
// Lisbon and Porto

echo $fmt->format(['Lisbon']);
// Lisbon

echo $fmt->format([]);
// (empty string)
Enter fullscreen mode Exit fullscreen mode

The two-item case drops the comma on its own. The single-item case returns the item. The empty case returns an empty string. You delete every branch in humanList() and the special-casing goes with it.

And, or, and units

The second constructor argument is the list type. Three constants, and their integer values are stable:

  • IntlListFormatter::TYPE_AND (0) — the default, an "and" list.
  • IntlListFormatter::TYPE_OR (1) — an "or" list.
  • IntlListFormatter::TYPE_UNITS (2) — compound units, no conjunction.
$or = new IntlListFormatter('en', IntlListFormatter::TYPE_OR);

echo $or->format(['card', 'PayPal', 'bank transfer']);
// card, PayPal, or bank transfer
Enter fullscreen mode Exit fullscreen mode

That "or" list is where the hand-rolled helper falls apart first. Nobody writes a second humanListOr(); they concatenate implode(', ', ...) inline in a Blade template and forget the last conjunction entirely. The type constant makes it one argument.

TYPE_UNITS is the odd one. It's for gluing measurements together with no "and":

$units = new IntlListFormatter(
    'en',
    IntlListFormatter::TYPE_UNITS,
);

echo $units->format(['3 hr', '25 min', '30 sec']);
// 3 hr, 25 min, 30 sec
Enter fullscreen mode Exit fullscreen mode

You'd reach for that when you've already formatted the individual quantities (with NumberFormatter or IntlDateFormatter) and want ICU to join them the way the target locale expects.

Width: short and narrow

The third argument controls how compact the join is. Again, three constants:

  • IntlListFormatter::WIDTH_WIDE (0) — the default, spelled-out form.
  • IntlListFormatter::WIDTH_SHORT (1) — a shorter join where the locale has one.
  • IntlListFormatter::WIDTH_NARROW (2) — the tightest form the locale allows.
$narrow = new IntlListFormatter(
    'en',
    IntlListFormatter::TYPE_UNITS,
    IntlListFormatter::WIDTH_NARROW,
);

echo $narrow->format(['3h', '25m', '30s']);
// 3h 25m 30s
Enter fullscreen mode Exit fullscreen mode

Wide is what you want for prose. Narrow earns its place in tight UI: a duration chip, a table cell, a mobile header where a full "3 hours, 25 minutes, and 30 seconds" would wrap three times. The width tables come from ICU, so the narrow form for one locale isn't the same shape as another. That's the point of not guessing.

The locale is the whole reason this exists

Everything above is convenience. This is the part the helper could never do:

$de = new IntlListFormatter('de');
echo $de->format(['Berlin', 'Hamburg', 'München']);
// Berlin, Hamburg und München

$deOr = new IntlListFormatter(
    'de',
    IntlListFormatter::TYPE_OR,
);
echo $deOr->format(['Berlin', 'Hamburg', 'München']);
// Berlin, Hamburg oder München

$es = new IntlListFormatter(
    'es',
    IntlListFormatter::TYPE_OR,
);
echo $es->format(['tarjeta', 'PayPal', 'transferencia']);
// tarjeta, PayPal o transferencia
Enter fullscreen mode Exit fullscreen mode

German joins the last item with und and drops the comma before it. Spanish uses o with no comma. Your English-shaped humanList() would have produced "Berlin, Hamburg, und München" with a comma that reads as wrong to a German speaker, and "tarjeta, PayPal, or transferencia" mixing English into Spanish. The formatter reads the user's locale and applies that language's own rules. You stop being the person who decided how German punctuates a list.

Feed the locale from the same place the rest of your i18n comes from, the request's resolved locale, not a hardcoded string:

$fmt = new IntlListFormatter(
    $request->getLocale(),
    IntlListFormatter::TYPE_AND,
);

echo $fmt->format($cityNames);
Enter fullscreen mode Exit fullscreen mode

Error handling and the gotchas

format() returns string|false. It hands back false on failure and exposes ICU's status through two methods, matching the rest of the Intl extension:

$fmt = new IntlListFormatter('en');
$result = $fmt->format($items);

if ($result === false) {
    throw new RuntimeException(
        'List format failed: ' . $fmt->getErrorMessage()
    );
}
Enter fullscreen mode Exit fullscreen mode

getErrorCode() returns ICU's integer status (0 on success), and getErrorMessage() returns the string form ("U_ZERO_ERROR" when all is well). A few things worth knowing before you wire it in:

  • It expects an array of strings. Format your numbers, dates, and money first, then hand the finished strings to the list formatter. It joins text; it doesn't know your currency.
  • Unknown locales fall back, they don't throw. ICU resolves an unrecognized locale down to its root data rather than erroring, so a typo in the locale gives you a plausible-looking English-ish list, not an exception. Validate the locale upstream if that matters.
  • Reuse the instance. Constructing a formatter loads ICU data. If you're formatting many lists in the same locale, build one formatter and call format() in the loop instead of new-ing per row.

Where this belongs in your code

Formatting a list for a human to read is a presentation concern. It depends on the reader's locale, which is a property of the request, not of your business rules. That means it belongs at the edge of the system, in the view layer or an output adapter, never in the domain.

Your domain holds $order->paymentMethods as an array of value objects. The use case returns that array untouched. The presenter, the thing that already knows the request locale, is where IntlListFormatter lives:

final readonly class OrderPresenter
{
    public function __construct(
        private string $locale,
    ) {}

    public function paymentMethods(Order $order): string
    {
        $labels = array_map(
            fn (PaymentMethod $m) => $m->label(),
            $order->paymentMethods,
        );

        return (new IntlListFormatter(
            $this->locale,
            IntlListFormatter::TYPE_OR,
        ))->format($labels);
    }
}
Enter fullscreen mode Exit fullscreen mode

The domain never learns what a comma is. Swap Blade for Twig, or your web front-end for an API that returns the same list pre-joined, and the formatting logic moves with the presenter because that's the only place it ever lived.

When to skip it

If your app is English-only and will stay that way, implode(', ', $items) for a machine-readable list is fine, and you don't need ICU to join two log fields. IntlListFormatter earns its keep the moment a human reads the output and the moment a second locale is on the roadmap. That covers most user-facing text: notification bodies, confirmation screens, "you selected X, Y, and Z" summaries, filter chips.

The helper you've been copying between projects for a decade was a locale database with one row in it. PHP 8.5 gives you the real one. Delete the row.


Locale-aware list joining is a small example of a rule that keeps ending up in the wrong layer. It's presentation logic, it depends on the request, and it has no business inside a domain object. Keeping that concern at the edge, in a presenter or an output adapter, is exactly the discipline that lets you swap frameworks and view engines without touching your core. That boundary between domain code and the framework glue around it is what Decoupled PHP works through, chapter by chapter.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)