Table of Contents

PHP RFC: IntlRelativeDateTimeFormatter

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 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.

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:

$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.

format()

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.

formatNumeric()

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个月后
 
?>

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, 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.

Error handling

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:

If intl.use_exceptions is enabled, ICU failures follow the existing intl exception behavior.

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, IntlRelativeDateTimeFormatter::UNIT_DAY), "\n";
// tomorrow
 
?>

If you don't know how IntlDateFormatter works, simply put, the difference is basically:

Backward Incompatible Changes

This 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.

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

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.

Open Issues

None.

Future Scope

None. Perhaps there will be some according to further discussion :)

Proposed Voting Choices

Primary vote requiring a 2/3 majority to accept the RFC:

Add IntlRelativeDateTimeFormatter to ext/intl?
Real name Yes No Abstain
Final result: 0 0 0
This poll has been closed.

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

References

Rejected Features

None.

Changelog