- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
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"
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)
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
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
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
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
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);
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()
);
}
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 ofnew-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);
}
}
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.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)