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.
Add a new class IntlRelativeDateTimeFormatter to ext/intl.
<?php /** * @not-serializable * @strict-properties */ final class IntlRelativeDateTimeFormatter { /** @cvalue UDAT_STYLE_LONG */ public const int STYLE_LONG = UNKNOWN; /** @cvalue UDAT_STYLE_SHORT */ public const int STYLE_SHORT = UNKNOWN; /** @cvalue UDAT_STYLE_NARROW */ public const int STYLE_NARROW = UNKNOWN; /** @cvalue UDISPCTX_CAPITALIZATION_NONE */ public const int CAPITALIZATION_NONE = UNKNOWN; /** @cvalue UDISPCTX_CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE */ public const int CAPITALIZATION_FOR_MIDDLE_OF_SENTENCE = UNKNOWN; /** @cvalue UDISPCTX_CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE */ public const int CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE = UNKNOWN; /** @cvalue UDISPCTX_CAPITALIZATION_FOR_UI_LIST_OR_MENU */ public const int CAPITALIZATION_FOR_UI_LIST_OR_MENU = UNKNOWN; /** @cvalue UDISPCTX_CAPITALIZATION_FOR_STANDALONE */ public const int CAPITALIZATION_FOR_STANDALONE = UNKNOWN; /** @cvalue UDAT_REL_UNIT_YEAR */ public const int UNIT_YEAR = UNKNOWN; /** @cvalue UDAT_REL_UNIT_QUARTER */ public const int UNIT_QUARTER = UNKNOWN; /** @cvalue UDAT_REL_UNIT_MONTH */ public const int UNIT_MONTH = UNKNOWN; /** @cvalue UDAT_REL_UNIT_WEEK */ public const int UNIT_WEEK = UNKNOWN; /** @cvalue UDAT_REL_UNIT_DAY */ public const int UNIT_DAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_HOUR */ public const int UNIT_HOUR = UNKNOWN; /** @cvalue UDAT_REL_UNIT_MINUTE */ public const int UNIT_MINUTE = UNKNOWN; /** @cvalue UDAT_REL_UNIT_SECOND */ public const int UNIT_SECOND = UNKNOWN; /** @cvalue UDAT_REL_UNIT_SUNDAY */ public const int UNIT_SUNDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_MONDAY */ public const int UNIT_MONDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_TUESDAY */ public const int UNIT_TUESDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_WEDNESDAY */ public const int UNIT_WEDNESDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_THURSDAY */ public const int UNIT_THURSDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_FRIDAY */ public const int UNIT_FRIDAY = UNKNOWN; /** @cvalue UDAT_REL_UNIT_SATURDAY */ public const int UNIT_SATURDAY = UNKNOWN; public function __construct( ?string $locale = null, int $style = self::STYLE_LONG, int $capitalizationContext = self::CAPITALIZATION_NONE, ) {} public function format(int|float $offset, int $unit): string|false {} public function formatNumeric(int|float $offset, int $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 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:
IntlRelativeDateTimeFormatter::STYLE_LONG, for example “in 3 days”IntlRelativeDateTimeFormatter::STYLE_SHORT, for example “in 3 days” or “in 3 d” depending on locale dataIntlRelativeDateTimeFormatter::STYLE_NARROW, the shortest locale-specific form
$capitalizationContext controls ICU's capitalization context. This is
useful for locales where relative date strings differ depending on whether they
appear in the middle of a sentence, or at the beginning of a sentence, or stand
alone, or even on menus, it goes on.
public function format(int|float $offset, int $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.
Examples for en_US:
<?php $formatter = new IntlRelativeDateTimeFormatter('en_US'); echo $formatter->format(-1, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // yesterday echo $formatter->format(0, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // today echo $formatter->format(1, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // tomorrow echo $formatter->format(-1, IntlRelativeDateTimeFormatter::UNIT_WEEK), "\n"; // last week echo $formatter->format(2, IntlRelativeDateTimeFormatter::UNIT_WEEK), "\n"; // in 2 weeks echo $formatter->format(-1, IntlRelativeDateTimeFormatter::UNIT_SUNDAY), "\n"; // last Sunday ?>
The sign of $offset is interpreted by ICU:
The weekday constants, such as
IntlRelativeDateTimeFormatter::UNIT_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.
public function formatNumeric(int|float $offset, int $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”.
<?php $formatter = new IntlRelativeDateTimeFormatter('en_US'); echo $formatter->formatNumeric(-1, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // 1 day ago echo $formatter->formatNumeric(1, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // in 1 day echo $formatter->formatNumeric(1.5, IntlRelativeDateTimeFormatter::UNIT_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, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n"; // il y a 1 jour echo $french->formatNumeric(1.5, IntlRelativeDateTimeFormatter::UNIT_HOUR), "\n"; // dans 1,5 heure $chinese = new IntlRelativeDateTimeFormatter('zh_CN'); echo $chinese->formatNumeric(3, IntlRelativeDateTimeFormatter::UNIT_MONTH), "\n"; // 3个月后 ?>
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, IntlRelativeDateTimeFormatter::UNIT_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.
TL;DR: Nothing special. This is exact what other classes do in the rest of the extension.
The class follows the existing ext/intl error handling conventions:
getErrorCode() and getErrorMessage() expose the object's last error.false on ICU formatting or conversion failure.ValueError.
If intl.use_exceptions is enabled, ICU failures follow the existing
intl exception behavior.
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, IntlRelativeDateTimeFormatter::UNIT_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 displayedThis RFC adds a new class to ext/intl.
Adding a new global class name can conflict with userland code declaring
IntlRelativeDateTimeFormatter while the intl extension is loaded.
This is the normal compatibility consideration for adding classes to extensions.
PHP 8.7
None.
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.
None.
The RFC adds the class constants listed in the proposed stub.
No deprecated ICU sentinel constants such as UDAT_STYLE_COUNT or
UDAT_REL_UNIT_COUNT are exposed.
None.
None. Perhaps there will be some according to further discussion :)
Primary vote requiring a 2/3 majority to accept the RFC:
TBD.
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
None.