rfc:reldateformatter

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 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 data
  • IntlRelativeDateTimeFormatterStyle::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() and getErrorMessage() expose the object's last error.
  • Methods return false on ICU formatting or conversion failure.
  • Passing an integer, a string, or a case of the wrong enum type for $style, $capitalizationContext, or $unit throws TypeError.

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 displayed
  • IntlRelativeDateTimeFormatter: How should this relative offset and unit be displayed

Backward Incompatible Changes

This RFC adds a new class and three enums to ext/intl:

  • IntlRelativeDateTimeFormatter
  • IntlRelativeDateTimeFormatterStyle
  • IntlRelativeDateTimeFormatterCapitalization
  • IntlRelativeDateTimeFormatterUnit

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:

Implement IntlRelativeDateTimeFormatter as described 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

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

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.
rfc/reldateformatter.txt · Last modified: by weilindu