PHP RFC: IntlRelativeDateTimeFormatter
- Version: 0.4.1
- Date: 2026-08-09
- Author: Weilin Du weilindu@php.net
- Status: Under Discussion
- Implementation: https://github.com/php/php-src/pull/23514
- Discussion thread: https://externals.io/message/132187
- Voting thread: TBD
Introduction
Formatting relative date and time values is a common requirement for user interfaces, notifications, activity feeds, logs, you name it. Examples include “3 days ago”, “in 2 weeks”, “yesterday”, and “next Sunday”. This RFC proposes to add a IntlRelativeDateTimeFormatter class to generate these date message in the intl extension.
PHP's intl extension already exposes IntlDateFormatter::RELATIVE_*
styles. Those styles format a concrete date or timestamp and may replace nearby
calendar dates with words such as “yesterday”, “today”, or “tomorrow”. They do
not provide an API for formatting a relative offset and a unit directly.
As a result, applications that need strings such as “5 minutes ago” or “in 3 quarters” often implement their own pluralization, unit naming, word order, and locale data handling, etc, etc in userland. This is difficult to do correctly across languages and duplicates functionality already provided by ICU.
ICU provides URelativeDateTimeFormatter, a stable C API since ICU 57,
for exactly this purpose. The intl extension already requires ICU 57.1 or newer.
This RFC proposes exposing that ICU formatter to PHP as a new
IntlRelativeDateTimeFormatter class.
Proposal
Add a new class IntlRelativeDateTimeFormatter and three enums to
ext/intl. The class and enums are defined in the global namespace.
<?php enum IntlRelativeDateTimeFormatterStyle { case Long; case Short; case Narrow; } enum IntlRelativeDateTimeFormatterCapitalization { case None; case MiddleOfSentence; case BeginningOfSentence; case UiListAndMenu; case Standalone; } enum IntlRelativeDateTimeFormatterUnit { case Year; case Quarter; case Month; case Week; case Day; case Hour; case Minute; case Second; case Sunday; case Monday; case Tuesday; case Wednesday; case Thursday; case Friday; case Saturday; } /** * @not-serializable * @strict-properties */ final class IntlRelativeDateTimeFormatter { public function __construct( ?string $locale = null, IntlRelativeDateTimeFormatterStyle $style = IntlRelativeDateTimeFormatterStyle::Long, IntlRelativeDateTimeFormatterCapitalization $capitalizationContext = IntlRelativeDateTimeFormatterCapitalization::None, ?NumberFormatter $numberFormatter = null, ) {} public function format(int|float $offset, IntlRelativeDateTimeFormatterUnit $unit): string|false {} public function formatNumeric(int|float $offset, IntlRelativeDateTimeFormatterUnit $unit): string|false {} public function combineDateAndTime(string $relativeDate, string $time): string|false {} public function getErrorCode(): int {} public function getErrorMessage(): string {} } ?>
No procedural aliases are proposed of course.
The three enums are pure enums, without integer or string backing values. The
$style, $capitalizationContext, and $unit
parameters accept only cases of their respective enum types. ICU's integer
values are mapped internally and are not part of the public API.
Constructor
The constructor creates an ICU URelativeDateTimeFormatter instance.
$locale accepts a locale identifier. Passing null uses the
default intl locale, as a good convention to the extension.
$style controls the output width:
IntlRelativeDateTimeFormatterStyle::Long, for example “in 3 days”IntlRelativeDateTimeFormatterStyle::Short, for example “in 3 days” or “in 3 d” depending on locale dataIntlRelativeDateTimeFormatterStyle::Narrow, the shortest locale-specific form
$capitalizationContext accepts an
IntlRelativeDateTimeFormatterCapitalization case and controls ICU's
capitalization context:
None: no capitalization adjustment; this is the default.MiddleOfSentence: the result appears in the middle of a sentence.BeginningOfSentence: the result appears at the beginning of a sentence.UiListAndMenu: the result is used in a UI list or menu.Standalone: the result is used as a standalone label.
The capitalization adjustment depends on the selected locale and ICU data.
$numberFormatter customizes how numeric offsets are formatted. Passing
null uses ICU's default number formatter for the selected locale.
The supplied NumberFormatter object is retained by
IntlRelativeDateTimeFormatter. Successful configuration changes made
after construction using NumberFormatter::setAttribute(),
NumberFormatter::setTextAttribute(),
NumberFormatter::setSymbol(), or
NumberFormatter::setPattern() are reflected by subsequent calls to
format() and formatNumeric().
Custom number formatting example
This example uses en_US and changes the supplied
NumberFormatter after constructing the relative formatter. The same
offset is displayed with the default decimal formatting, with two fractional
digits, and rounded to a whole number:
<?php $numberFormatter = new NumberFormatter('en_US', NumberFormatter::DECIMAL); $formatter = new IntlRelativeDateTimeFormatter( 'en_US', numberFormatter: $numberFormatter, ); echo $formatter->formatNumeric(1.5, IntlRelativeDateTimeFormatterUnit::Hour), "\n"; // in 1.5 hours $numberFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 2); echo $formatter->formatNumeric(1.5, IntlRelativeDateTimeFormatterUnit::Hour), "\n"; // in 1.50 hours echo $formatter->format(3, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // in 3.00 days echo $formatter->format(-1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // yesterday $numberFormatter->setAttribute(NumberFormatter::ROUNDING_MODE, NumberFormatter::ROUND_HALFUP); $numberFormatter->setAttribute(NumberFormatter::FRACTION_DIGITS, 0); echo $formatter->formatNumeric(1.5, IntlRelativeDateTimeFormatterUnit::Hour), "\n"; // in 2 hours ?>
format()
public function format(int|float $offset, IntlRelativeDateTimeFormatterUnit $unit): string|false
Formats $offset and $unit using a text form when one is
available in the locale, and falls back to a numeric form otherwise.
If a custom NumberFormatter was passed to the constructor, ICU uses it
when numeric fallback is needed.
Examples for en_US:
<?php $formatter = new IntlRelativeDateTimeFormatter('en_US'); echo $formatter->format(-1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // yesterday echo $formatter->format(0, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // today echo $formatter->format(1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // tomorrow echo $formatter->format(-1, IntlRelativeDateTimeFormatterUnit::Week), "\n"; // last week echo $formatter->format(2, IntlRelativeDateTimeFormatterUnit::Week), "\n"; // in 2 weeks echo $formatter->format(-1, IntlRelativeDateTimeFormatterUnit::Sunday), "\n"; // last Sunday ?>
The sign of $offset is interpreted by ICU:
- Negative values refer to the past.
- Positive values refer to the future.
- Zero refers to the current unit where locale data provides a text form.
The weekday enum cases, such as
IntlRelativeDateTimeFormatterUnit::Sunday, represent named weekdays
for phrases such as “last Sunday”, “this Sunday”, and “next Sunday”. They are
not durations of a fixed number of days.
formatNumeric()
public function formatNumeric(int|float $offset, IntlRelativeDateTimeFormatterUnit $unit): string|false
Formats $offset and $unit using a numeric form. Unlike
format(), this method does not use special text forms such as
“yesterday”, “today”, or “tomorrow”.
If a custom NumberFormatter was passed to the constructor, ICU uses it
to format the numeric offset.
<?php $formatter = new IntlRelativeDateTimeFormatter('en_US'); echo $formatter->formatNumeric(-1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // 1 day ago echo $formatter->formatNumeric(1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // in 1 day echo $formatter->formatNumeric(1.5, IntlRelativeDateTimeFormatterUnit::Hour), "\n"; // in 1.5 hours ?>
NOTE: All these functions work for multiple locales of course. I am only using en_US for convenience. These works too:
<?php $french = new IntlRelativeDateTimeFormatter('fr_FR'); echo $french->formatNumeric(-1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // il y a 1 jour echo $french->formatNumeric(1.5, IntlRelativeDateTimeFormatterUnit::Hour), "\n"; // dans 1,5 heure $chinese = new IntlRelativeDateTimeFormatter('zh_CN'); echo $chinese->formatNumeric(3, IntlRelativeDateTimeFormatterUnit::Month), "\n"; // 3个月后 ?>
combineDateAndTime()
public function combineDateAndTime(string $relativeDate, string $time): string|false
Combines an already formatted relative date string and an already formatted time string using the locale's date-time separator.
Example for en_US:
<?php $formatter = new IntlRelativeDateTimeFormatter('en_US'); $relativeDate = $formatter->format(-1, IntlRelativeDateTimeFormatterUnit::Day); echo $formatter->combineDateAndTime($relativeDate, '3:45 PM'), "\n"; // yesterday at 3:45 PM ?>
This method does not parse, validate, or format the supplied time string. It only combines two strings according to locale data.
Error handling
The class follows the existing ext/intl error handling conventions for ICU operations, with PHP's normal type checking for enum parameters:
- Formatting and string-combination methods reset the global intl error and the object's error before the ICU operation.
- ICU formatting or conversion failures set both the object error and the global intl error.
getErrorCode()andgetErrorMessage()expose the object's last error.- Methods return
falseon ICU formatting or conversion failure. - Passing an integer, a string, or a case of the wrong enum type for
$style,$capitalizationContext, or$unitthrowsTypeError.
If intl.use_exceptions is enabled, ICU failures follow the existing
intl exception behavior. Enum type errors always throw TypeError,
independently of this setting.
IntlRelativeDateTimeFormatter V.S. IntlDateFormatter::RELATIVE_*
TL;DR: as some may ask: This RFC does not replace or change IntlDateFormatter::RELATIVE_*.
IntlDateFormatter formats concrete dates and times. Its relative
date styles are date-formatting styles: they may display a concrete timestamp as
“yesterday”, “today”, or “tomorrow” when appropriate, and otherwise display an
absolute date according to the selected style.
IntlRelativeDateTimeFormatter formats a relative offset and unit. It
does not accept timestamps, time zones, calendars, or DateTimeInterface
objects. It also does not choose the best unit. Userland code remains
responsible for deciding whether a duration should be displayed as “in 7 days”,
“in 1 week”, or with another unit.
The overlap is intentionally limited to nearby day values:
<?php $dateFormatter = new IntlDateFormatter( 'en_US', IntlDateFormatter::RELATIVE_LONG, IntlDateFormatter::NONE, ); echo $dateFormatter->format(new DateTimeImmutable('tomorrow')), "\n"; // tomorrow $relativeFormatter = new IntlRelativeDateTimeFormatter('en_US'); echo $relativeFormatter->format(1, IntlRelativeDateTimeFormatterUnit::Day), "\n"; // tomorrow ?>
If you don't know how IntlDateFormatter works, simply put, the difference is basically:
IntlDateFormatter: How should this date or time be displayedIntlRelativeDateTimeFormatter: How should this relative offset and unit be displayed
Backward Incompatible Changes
This RFC adds a new class and three enums to ext/intl:
IntlRelativeDateTimeFormatterIntlRelativeDateTimeFormatterStyleIntlRelativeDateTimeFormatterCapitalizationIntlRelativeDateTimeFormatterUnit
These global names can conflict with userland declarations while the intl extension is loaded. Existing ext/intl classes and their constants are unchanged.
Proposed PHP Version(s)
PHP 8.7
RFC Impact
To SAPIs
None.
To Existing Extensions
No existing extension behavior changes.
Extensions may choose to use IntlRelativeDateTimeFormatter in future
human-facing APIs, but this RFC does not require any changes outside ext/intl.
To Opcache
None.
New Constants
No new global constants or integer class constants are added. The available styles, capitalization contexts, and units are exposed as the cases of the three enums listed in the proposed stub.
No deprecated ICU sentinel constants such as UDAT_STYLE_COUNT or
UDAT_REL_UNIT_COUNT are exposed as enum cases.
Open Issues
None.
Future Scope
A broader ext/intl modernization RFC could introduce a namespaced API and
consider enum-based alternatives for existing intl classes. This RFC introduces
enums for the new formatter without changing existing APIs or reserving an
Intl namespace for the current ICU wrappers.
Voting
Voting starts on 2026-09-15 at 11:14:40 UTC and ends on 2026-09-29 at 11:14:40 UTC.
Primary vote requiring a 2/3 majority to accept the RFC:
Patches and Tests
TBD.
Implementation
The implementation is expected to add a new ext/intl module directory:
ext/intl/reldateformatter/reldateformatter.stub.php ext/intl/reldateformatter/reldateformatter_arginfo.h ext/intl/reldateformatter/reldateformatter_class.h ext/intl/reldateformatter/reldateformatter_class.cpp ext/intl/tests/reldateformatter/*.phpt
It also needs to update:
ext/intl/config.m4 ext/intl/config.w32 ext/intl/php_intl.c
The implementation must register the three pure enums and use their types in
the generated arginfo. Enum arguments can be parsed using
Z_PARAM_ENUM() with the corresponding enum class entry. Their cases
are mapped internally to ICU's UDateRelativeDateTimeFormatterStyle,
capitalization-related UDisplayContext values, and
URelativeDateTimeUnit, respectively. In particular,
IntlRelativeDateTimeFormatterCapitalization::UiListAndMenu maps to
UDISPCTX_CAPITALIZATION_FOR_UI_LIST_OR_MENU.
When $numberFormatter is provided, the implementation should clone
the underlying ICU UNumberFormat and pass the clone as the
nfToAdopt argument to ureldatefmt_open(). When it is
null, the implementation should pass NULL.
References
- ICU URelativeDateTimeFormatter C API: https://raw.githubusercontent.com/unicode-org/icu/main/icu4c/source/i18n/unicode/ureldatefmt.h
- ICU UDisplayContext C API: https://raw.githubusercontent.com/unicode-org/icu/main/icu4c/source/common/unicode/udisplaycontext.h
- Tim Düsterhus's proposal for global enums: https://externals.io/message/132187#132513
Rejected Features
Class name and namespace
People raised whether this class should use an appropriate namespace to avoid
conflicts. This RFC keeps the global IntlRelativeDateTimeFormatter
name because ext/intl currently exposes similar formatter classes as global
classes, for example IntlDateFormatter,
IntlListFormatter, IntlNumberRangeFormatter, and
IntlDatePatternGenerator.
The three enums also use global, formatter-specific names. A broader redesign of ext/intl can introduce a namespaced API separately.
Integer constants for the new API
Earlier drafts used integer class constants to match existing ext/intl APIs. Following Tim Düsterhus's feedback, this RFC instead uses three global enums. They make the accepted values explicit in the signatures and prevent mixing styles, capitalization contexts, and units.
This also improves IDE completion and allows documentation for each enum and
its cases. Enums are already used by PHP's standard library, for example
RoundingMode. These benefits justify the difference from older
ext/intl APIs without requiring a namespace change.
Changelog
- 2026-09-22: Add an example of custom NumberFormatter precision, rounding, and configuration changes after construction.
- 2026-09-19: Adopt Tim Düsterhus's proposal for three global pure enums; update parameter types, examples, error handling, and implementation notes.
- 2026-09-08: Address Tim Düsterhus's feedback about observing subsequent NumberFormatter configuration changes.
- 2026-08-09: Address David's feedback about namespace, enums, and the number formatter argument of ureldatefmt_open().
- 2026-08-07: Initial version.