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.
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
'@…' 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";
}
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);
}
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);
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
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 |
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