<< Home

Chapter 13: Dates, Times, and Internationalization

Working with dates and localized text is a common requirement for web applications. PHP provides two complementary sets of tools: the core DateTime API for precise arithmetic and formatting, and the intl extension for locale-aware presentation. This chapter introduces reliable strategies for calculating with dates, formatting for users in different regions, and comparing human text correctly.

⚠️ Always store timestamps in UTC; convert to the user’s timezone only when displaying or accepting input.

DateTime, timezones, and DateInterval

The core DateTime family models points in time with timezone awareness, while DateInterval represents measured differences. You can also iterate recurring spans using DatePeriod for schedules and reports.

Constructing a DateTime with timezone awareness

Create a DateTime from a string or timestamp; pass a DateTimeZone when the source is not UTC. The object stores absolute time internally and tracks the chosen zone for presentation.

<?php
// Now in UTC
$utcNow = new DateTime('now', new DateTimeZone('UTC'));

// Specific local time
$la = new DateTime('2030-07-14 09:30', new DateTimeZone('America/Los_Angeles'));

// From UNIX timestamp
$fromTs = (new DateTime('@1730700000'))->setTimezone(new DateTimeZone('Europe/London'));

// Changing display timezone
$la->setTimezone(new DateTimeZone('Europe/Paris'));
echo $la->format('Y-m-d H:i T');  // 2030-07-14 18:30 CEST
💡 Strings like '@…' are interpreted as UNIX timestamps. Always set a display timezone afterward for human-readable output.

Measuring time with DateInterval and doing arithmetic

DateInterval stores durations such as 3 days or 1 month. Add or subtract intervals to move a date forward or backward; months and years account for variable lengths correctly.

<?php
$start = new DateTime('2030-01-31', new DateTimeZone('UTC'));

// P1M means a period of one month
$oneMonth = new DateInterval('P1M');
$next = (clone $start)->add($oneMonth);                // 2030-02-28 (leap rules apply)
$prev = (clone $start)->sub(new DateInterval('P2D'));  // 2030-01-29

echo $next->format('Y-m-d');
echo "\n";
echo $prev->format('Y-m-d');

Iterating recurring spans with DatePeriod

Use DatePeriod to generate occurrences between a start and an end given a stepping interval. This is ideal for calendars, invoices, and reports.

<?php
$start = new DateTime('2030-10-01', new DateTimeZone('UTC'));
$end   = new DateTime('2030-10-31 23:59:59', new DateTimeZone('UTC'));
$step  = new DateInterval('P1W');  // weekly

$period = new DatePeriod($start, $step, $end);
foreach ($period as $dt) {
  echo $dt->format('Y-m-d'), "\n";
}
⚠️ Daylight saving transitions can cause repeated or skipped times in local zones. Use UTC for storage and arithmetic; convert for display.

Formatting and parsing

Formatting turns a DateTime into a string; parsing turns a string into a DateTime. For machine-stable formats use DateTime::format and DateTime::createFromFormat. For locale-aware formats use IntlDateFormatter from the intl extension.

Using DateTime::format with tokens

Token strings like 'Y-m-d H:i:s' control output. Use ISO-8601 or RFC-3339 for APIs and logs to keep values unambiguous.

<?php
$dt = new DateTime('2030-11-04 14:00', new DateTimeZone('Europe/London'));
echo $dt->format(DateTime::RFC3339);  // 2030-11-04T14:00:00+00:00
echo "\n";
echo $dt->format('l, d F Y H:i T');   // Tuesday, 04 November 2030 14:00 GMT

Parsing with DateTime::createFromFormat

When you know the exact input pattern, create dates reliably by matching the tokens. Always check for parsing errors and set the timezone explicitly.

<?php
$tz = new DateTimeZone('America/New_York');
$input = '11/04/2030 09:15';
$dt = DateTime::createFromFormat('m/d/Y H:i', $input, $tz);

if ($dt === false) {
  foreach (DateTime::getLastErrors()['errors'] as $e) {
    error_log($e);
  }
} else {
  echo $dt->setTimezone(new DateTimeZone('UTC'))->format(DateTime::RFC3339);
}
💡 Prefer numeric, unambiguous input formats for end users such as YYYY-MM-DD). If you must accept regional formats, require an explicit locale or show an example near the input field.

Localizing output with IntlDateFormatter

IntlDateFormatter uses ICU patterns and locale rules for month names, weekday names, and numbering systems. Choose date and time styles or provide a custom skeleton.

<?php
$dt = new DateTime('2030-11-04 14:00', new DateTimeZone('Europe/London'));

$fmtEn = new IntlDateFormatter(
  'en_GB',
  IntlDateFormatter::FULL,
  IntlDateFormatter::SHORT,
  'Europe/London'
);

$fmtFr = new IntlDateFormatter(
  'fr_FR',
  IntlDateFormatter::FULL,
  IntlDateFormatter::SHORT,
  'Europe/Paris'
);

echo $fmtEn->format($dt);  // Tuesday, 4 November 2030 at 14:00
echo "\n";
echo $fmtFr->format($dt);  // mardi 4 novembre 2030 à 15:00

Working with DateTimeImmutable

DateTimeImmutable performs the same operations as DateTime) but returns a new object for every change. This removes accidental mutation, which makes code easier to reason about in complex flows.

Avoiding accidental mutation by cloning on change

With DateTimeImmutable, calls to add, sub, or setTimezone return a new instance; the original stays the same.

<?php
$base = new DateTimeImmutable('2030-03-01 10:00', new DateTimeZone('UTC'));
$a = $base->add(new DateInterval('P1D'));
$b = $base->setTimezone(new DateTimeZone('Asia/Tokyo'));

echo $base->format('c');  // 2030-03-01T10:00:00+00:00
echo "\n";
echo $a->format('c');     // 2030-03-02T10:00:00+00:00
echo "\n";
echo $b->format('c');     // 2030-03-01T19:00:00+09:00

Switching between mutable and immutable where needed

Convert to mutable with DateTime::createFromImmutable for APIs that require DateTime, or build immutables from mutable instances when you need safety.

<?php
$mutable = new DateTime('now', new DateTimeZone('UTC'));
$immutable = DateTimeImmutable::createFromMutable($mutable);

// Back to mutable
$back = DateTime::createFromImmutable($immutable);
⚠️ Choose one style for a given subsystem to keep behavior consistent. Mixing freely can hide subtle bugs in shared helpers.

Localization with intl

The intl extension relies on ICU to present language- and region-appropriate text. Localization is more than translation; it includes date formats, numbering systems, plural rules, and collation.

Selecting an appropriate locale tag

Locales follow the pattern language_REGION@keywordsLocale::acceptFromHttp to negotiate from Accept-Language.

<?php
$header = $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '';
$locale = Locale::acceptFromHttp($header) ?: 'en_GB';
Locale::setDefault($locale);

Formatting dates with IntlDateFormatter skeletons

Skeletons describe desired fields without fixing literal text. ICU maps them to idiomatic patterns per locale.

<?php
$dt = new DateTime('2030-11-04 14:00', new DateTimeZone('Europe/London'));
$fmt = IntlDateFormatter::create(
  'de_DE',
  IntlDateFormatter::NONE,
  IntlDateFormatter::NONE,
  'Europe/Berlin',
  IntlDateFormatter::GREGORIAN,
  'yyyyMMMEd HH:mm'  // skeleton
);
echo $fmt->format($dt);  // Di., 4. Nov. 2030 15:00
💡 Keep translated strings outside code files and load per locale. PHP’s MessageFormatter supports plural rules and placeholders cleanly.

Handling pluralization with MessageFormatter

Plural forms depend on the language. MessageFormatter applies the correct grammar for counts automatically when configured with a locale.

<?php
$fmt = new MessageFormatter('en_GB',
  '{count, plural, =0{No items} one{# item} other{# items}} in cart'
);
echo $fmt->format(['count' => 1]);

Number and currency formatting

Use NumberFormatter to format quantities, percentages, and currencies according to locale rules, including grouping separators and currency symbols.

Presenting quantities with NumberFormatter

Create a formatter with a style such as DECIMAL or PERCENT; apply minimum and maximum fraction digits as needed for your domain.

<?php
$nf = new NumberFormatter('fr_FR', NumberFormatter::DECIMAL);
$nf->setAttribute(NumberFormatter::MIN_FRACTION_DIGITS, 2);
echo $nf->format(12345.6);  // 12 345,60

Displaying money with CURRENCY style

Format amounts with the correct symbol, spacing, and rounding for the user’s locale. Always store amounts in minor units or decimal strings to avoid floating point drift.

<?php
$money = new NumberFormatter('en_GB', NumberFormatter::CURRENCY);
echo $money->formatCurrency(1999.5, 'GBP');    // £1,999.50

$moneyDe = new NumberFormatter('de_DE', NumberFormatter::CURRENCY);
echo $moneyDe->formatCurrency(1999.5, 'EUR');  // 1.999,50 €
Locale Amount Output
en_GB 1999.5 GBP £1,999.50
de_DE 1999.5 EUR 1.999,50 €
hi_IN 1999.5 INR ₹1,999.50
⚠️ Do not format for storage; serialize plain numbers and ISO currency codes. Formatting is a presentation concern only.

Calendars and collation

Some users follow non-Gregorian calendars, and different languages sort words using rules that are not simple binary comparisons. The intl extension provides calendar types and collation strategies to handle these cases correctly.

Working with non-Gregorian calendars via IntlCalendar

IntlCalendar exposes calendars such as Gregorian, Islamic, Hebrew, and Japanese. You can construct a calendar for a locale and timezone, then read or set fields.

<?php
// Japanese calendar example
$cal = IntlCalendar::createInstance('Asia/Tokyo', 'ja_JP@calendar=japanese');
$cal->set(2030, 10, 4, 14, 0, 0);  // month is 0-based: 10 = November
echo $cal->get(IntlCalendar::FIELD_ERA), "\n";
echo $cal->get(IntlCalendar::FIELD_YEAR);

Formatting calendar values with locale rules

IntlDateFormatter can target alternative calendars by specifying the @calendar=… keyword in the locale tag.

<?php
$dt = new DateTime('2030-11-04 14:00', new DateTimeZone('Asia/Tokyo'));
$fmt = IntlDateFormatter::create(
  'ar_EG@calendar=islamic',
  IntlDateFormatter::FULL,
  IntlDateFormatter::SHORT,
  'Africa/Cairo'
);
echo $fmt->format($dt);

Sorting human text with Collator

String comparison should respect language rules for accents and case. Collator implements ICU collation so sorted lists look natural to native speakers.

<?php
$words = ['ábaco', 'azúcar', 'árbol', 'avión'];
$coll = new Collator('es_ES'); // Spanish collation
$coll->asort($words);
print_r($words);

Tuning collation strength for searching

Adjust strength to control sensitivity to case and accents. Weaker strengths are useful for find-as-you-type; stronger strengths are better for canonical ordering.

<?php
$coll = new Collator('de_DE');
$coll->setStrength(Collator::PRIMARY);  // accent-insensitive, case-insensitive
var_dump($coll->compare('ä', 'a'));     // 0 at PRIMARY strength
💡 Normalize text to NFC before storing or comparing. ICU functions handle many differences, but normalized data simplifies downstream logic.