Date & Time #
Working with dates and times is one of the areas of PHP with the most traps — inconsistent timezones, different formats across systems, DST (Daylight Saving Time) shifting hours unpredictably, and wrong comparisons because the timezone isn’t considered. PHP provides two main classes: DateTime, which is mutable (can be modified in place), and DateTimeImmutable, which is safer because every operation produces a new object. This article covers both in depth — including DateInterval, DatePeriod for iterating date ranges, all the important format characters, correct timezone strategy, and the traps that frequently cause bugs in production applications.
DateTime vs DateTimeImmutable #
The most important difference to understand before starting: DateTime is mutable — the add(), sub(), and modify() methods change the same object. DateTimeImmutable is immutable — every method returns a new object without changing the old one.
<?php
// DateTime — MUTABLE, modifies the original object
$dt = new \DateTime('2024-01-15');
$tomorrow = $dt->add(new \DateInterval('P1D')); // modifies $dt!
echo $dt->format('Y-m-d'); // 2024-01-16 — $dt CHANGED!
echo $tomorrow->format('Y-m-d'); // 2024-01-16 — $tomorrow and $dt are THE SAME OBJECT
// DateTimeImmutable — IMMUTABLE, always returns a new object
$dti = new \DateTimeImmutable('2024-01-15');
$tomorrow = $dti->add(new \DateInterval('P1D')); // $dti is UNCHANGED
echo $dti->format('Y-m-d'); // 2024-01-15 — still the same
echo $tomorrow->format('Y-m-d'); // 2024-01-16 — a new object
Always useDateTimeImmutableas the default choice — its behavior is more predictable and prevents subtle bugs from accidental object mutation. UseDateTimeonly if there’s a specific reason requiring in-place mutation.
Creating DateTime Objects #
There are several ways to create a datetime object depending on the data source:
<?php
// The current time
$now = new \DateTimeImmutable();
$now = new \DateTimeImmutable('now');
// From relative strings
$tomorrow = new \DateTimeImmutable('+1 day');
$lastWeek = new \DateTimeImmutable('-1 week');
$firstOfMonth = new \DateTimeImmutable('first day of this month');
$lastOfMonth = new \DateTimeImmutable('last day of this month');
$nextMonday = new \DateTimeImmutable('next Monday');
// From standard date strings
$date = new \DateTimeImmutable('2024-03-15');
$datetime = new \DateTimeImmutable('2024-03-15 14:30:00');
$iso8601 = new \DateTimeImmutable('2024-03-15T14:30:00+07:00');
// From a custom format — safest for user input
$fromSlash = \DateTimeImmutable::createFromFormat('d/m/Y', '15/03/2024');
$fromNamed = \DateTimeImmutable::createFromFormat('d F Y', '15 March 2024');
// From a Unix timestamp
$fromTimestamp = new \DateTimeImmutable('@1710504000'); // @ prefix for timestamps
$also = (new \DateTimeImmutable())->setTimestamp(1710504000);
// Get a timestamp from an object
echo $now->getTimestamp(); // Unix timestamp (seconds since 1970-01-01 UTC)
createFromFormat — Safe Parsing
#
createFromFormat is a far safer way to parse date strings from user input than letting PHP guess the format itself:
<?php
// ANTI-PATTERN: letting PHP guess the format — can be misinterpreted
$wrong = new \DateTimeImmutable('03/04/2024');
// Is this April 3 or March 4? Depends on the locale!
// CORRECT: explicit format
$right = \DateTimeImmutable::createFromFormat('d/m/Y', '03/04/2024');
// April 3, 2024 — unambiguous
// Catch parsing errors
$result = \DateTimeImmutable::createFromFormat('Y-m-d', 'not-a-date');
if ($result === false) {
$errors = \DateTimeImmutable::getLastErrors();
echo "Parsing failed: " . implode(', ', $errors['errors']);
}
// Formats with time
$formats = [
'Y-m-d', // 2024-03-15
'd/m/Y', // 15/03/2024
'd-m-Y H:i:s', // 15-03-2024 14:30:00
'D, d M Y H:i:s O', // Fri, 15 Mar 2024 14:30:00 +0700
'U', // Unix timestamp
];
Formatting Dates #
The format() method converts a datetime object into a string. PHP’s format characters follow conventions from the C strftime function, with some additions:
Important Format Characters #
| Character | Description | Example Output |
|---|---|---|
Y | 4-digit year | 2024 |
y | 2-digit year | 24 |
m | 2-digit month | 03 |
n | Month without leading zero | 3 |
M | Short month name (English) | Mar |
F | Full month name (English) | March |
d | 2-digit day | 05 |
j | Day without leading zero | 5 |
D | Short day name (English) | Fri |
l | Full day name (English) | Friday |
N | Day of week (1=Monday, 7=Sunday) | 5 |
w | Day of week (0=Sunday, 6=Saturday) | 5 |
H | 24-hour, 2-digit | 14 |
G | 24-hour, no leading zero | 14 |
h | 12-hour, 2-digit | 02 |
i | 2-digit minutes | 30 |
s | 2-digit seconds | 00 |
A | AM or PM | PM |
a | am or pm | pm |
U | Unix timestamp | 1710504000 |
W | Week number of the year (ISO 8601) | 11 |
t | Number of days in the month | 31 |
L | Is it a leap year (1/0) | 1 |
Z | Timezone offset in seconds | 25200 |
P | Timezone offset +HH:MM | +07:00 |
O | Timezone offset +HHMM | +0700 |
e | Timezone name | Asia/Jakarta |
c | Full ISO 8601 | 2024-03-15T14:30:00+07:00 |
r | RFC 2822 | Fri, 15 Mar 2024 14:30:00 +0700 |
<?php
$dt = new \DateTimeImmutable('2024-03-15 14:30:45', new \DateTimeZone('Asia/Jakarta'));
echo $dt->format('Y-m-d H:i:s'); // 2024-03-15 14:30:45
echo $dt->format('d/m/Y'); // 15/03/2024
echo $dt->format('l, d F Y'); // Friday, 15 March 2024
echo $dt->format('H:i'); // 14:30
echo $dt->format('g:i A'); // 2:30 PM
echo $dt->format('c'); // 2024-03-15T14:30:45+07:00
echo $dt->format('U'); // Unix timestamp
// Formats for a MySQL database
echo $dt->format('Y-m-d H:i:s'); // 2024-03-15 14:30:45 — datetime
echo $dt->format('Y-m-d'); // 2024-03-15 — date only
DateInterval — Representing Time Spans #
DateInterval represents a duration of time — not a point in time. Its notation follows the ISO 8601 standard with the P prefix (Period):
P[years]Y[months]M[days]DT[hours]H[minutes]M[seconds]S
The T part separates date components from time components.
<?php
// ISO 8601 notation examples
$1day = new \DateInterval('P1D'); // 1 day
$1week = new \DateInterval('P1W'); // 1 week (= 7 days)
$1month = new \DateInterval('P1M'); // 1 month
$1year = new \DateInterval('P1Y'); // 1 year
$2y3m = new \DateInterval('P2Y3M'); // 2 years 3 months
$90minutes = new \DateInterval('PT90M'); // 90 minutes (T before time components)
$1h30m = new \DateInterval('PT1H30M'); // 1 hour 30 minutes
$complete = new \DateInterval('P1Y2M3DT4H5M6S'); // full
// Add and subtract with DateInterval
$now = new \DateTimeImmutable('2024-01-15 10:00:00');
$tomorrow = $now->add(new \DateInterval('P1D'));
$yesterday = $now->sub(new \DateInterval('P1D'));
$nextMonth = $now->add(new \DateInterval('P1M'));
$in2Hours = $now->add(new \DateInterval('PT2H'));
echo $tomorrow->format('Y-m-d'); // 2024-01-16
echo $yesterday->format('Y-m-d'); // 2024-01-14
echo $nextMonth->format('Y-m-d'); // 2024-02-15
echo $in2Hours->format('H:i'); // 12:00
modify() — Modifying with Relative Strings
#
An alternative to DateInterval for simple operations:
<?php
$dt = new \DateTimeImmutable('2024-03-15 14:30:00');
echo $dt->modify('+1 day')->format('Y-m-d'); // 2024-03-16
echo $dt->modify('+1 month')->format('Y-m-d'); // 2024-04-15
echo $dt->modify('+1 year')->format('Y-m-d'); // 2025-03-15
echo $dt->modify('next Monday')->format('Y-m-d'); // the next Monday
echo $dt->modify('first day of next month')->format('Y-m-d'); // 2024-04-01
echo $dt->modify('last day of this month')->format('Y-m-d'); // 2024-03-31
echo $dt->modify('midnight')->format('Y-m-d H:i:s'); // 2024-03-15 00:00:00
echo $dt->modify('noon')->format('Y-m-d H:i:s'); // 2024-03-15 12:00:00
// ANTI-PATTERN: +1 month at the end of a month — produces a surprising date
$endOfJanuary = new \DateTimeImmutable('2024-01-31');
echo $endOfJanuary->modify('+1 month')->format('Y-m-d'); // 2024-03-02!
// January has 31 days, February has 28/29 — PHP "overflows" into March
// CORRECT: use 'first day of next month' if you need the start of the next month
echo $endOfJanuary->modify('first day of next month')->format('Y-m-d'); // 2024-02-01
The+1 monthtrap — PHP literally adds 1 to the month value, then normalizes the date. If the day exceeds the number of days in the target month, PHP “overflows” into the next month.2024-01-31 + 1 month = 2024-03-02because February 2024 only has 29 days. For reliable monthly navigation, always reset to the first day or usefirst day of next month.
Calculating Differences — diff()
#
The diff() method returns a DateInterval representing the difference between two datetimes:
<?php
$birth = new \DateTimeImmutable('1995-08-17');
$now = new \DateTimeImmutable('2024-03-15');
$diff = $birth->diff($now);
echo $diff->y; // 28 — years
echo $diff->m; // 6 — remaining months
echo $diff->d; // 26 — remaining days
echo $diff->days; // 10437 — total days (special property)
echo $diff->h; // remaining hours
echo $diff->invert; // 0 if positive, 1 if negative (past to future)
// Formatting the diff
echo $diff->format('%y years %m months %d days');
// "28 years 6 months 26 days"
echo $diff->format('%R%a days');
// "+10437 days" — %R for the sign, %a for total days
// Calculate age
function calculateAge(\DateTimeInterface $birthDate): int
{
return $birthDate->diff(new \DateTimeImmutable())->y;
}
echo calculateAge(new \DateTimeImmutable('1995-08-17')); // age in years
// Check whether a deadline has passed
function isOverdue(\DateTimeInterface $deadline): bool
{
return $deadline < new \DateTimeImmutable();
}
// Difference in a specific unit
function differenceInHours(\DateTimeInterface $a, \DateTimeInterface $b): float
{
return abs($a->getTimestamp() - $b->getTimestamp()) / 3600;
}
function differenceInDays(\DateTimeInterface $a, \DateTimeInterface $b): int
{
return (int) abs($a->diff($b)->days);
}
$start = new \DateTimeImmutable('2024-03-01');
$end = new \DateTimeImmutable('2024-03-15');
echo differenceInDays($start, $end); // 14
DatePeriod — Iterating Date Ranges #
DatePeriod enables iteration over a date range with a specific interval — very useful for building calendars, weekly reports, or recurring schedules:
<?php
// Iterate every day in March 2024
$start = new \DateTimeImmutable('2024-03-01');
$end = new \DateTimeImmutable('2024-04-01'); // not inclusive
$interval = new \DateInterval('P1D');
$period = new \DatePeriod($start, $interval, $end);
foreach ($period as $date) {
echo $date->format('Y-m-d l') . "\n";
// 2024-03-01 Friday
// 2024-03-02 Saturday
// ... until 2024-03-31
}
// Iterate weekdays only (Monday-Friday)
foreach ($period as $date) {
$dayOfWeek = (int) $date->format('N'); // 1=Monday, 7=Sunday
if ($dayOfWeek <= 5) {
echo $date->format('d/m/Y') . "\n";
}
}
// Iterate every week
$weeklyPeriod = new \DatePeriod(
new \DateTimeImmutable('2024-01-01'),
new \DateInterval('P1W'),
new \DateTimeImmutable('2024-12-31')
);
$weekCount = iterator_count($weeklyPeriod);
echo "Number of weeks in 2024: $weekCount"; // 52
// Iterate every month
$monthlyPeriod = new \DatePeriod(
new \DateTimeImmutable('2024-01-01'),
new \DateInterval('P1M'),
new \DateTimeImmutable('2025-01-01')
);
foreach ($monthlyPeriod as $month) {
$startOfMonth = $month->format('Y-m-01');
$endOfMonth = $month->modify('last day of this month')->format('Y-m-d');
echo "$startOfMonth to $endOfMonth\n";
}
// Create 30-minute schedule slots in a day
$scheduleSlots = new \DatePeriod(
new \DateTimeImmutable('2024-03-15 08:00:00'),
new \DateInterval('PT30M'),
new \DateTimeImmutable('2024-03-15 17:00:00')
);
foreach ($scheduleSlots as $slot) {
echo $slot->format('H:i') . "\n"; // 08:00, 08:30, 09:00, ...
}
Timezones — Avoiding Timezone Problems #
Timezone is the most common source of bugs in applications serving users from different regions. The correct strategy: store all times in UTC in the database, convert to the local timezone only when displaying.
<?php
// Set the application's default timezone — do this once at bootstrap
date_default_timezone_set('UTC'); // Always UTC for storage
// Or via php.ini:
// date.timezone = UTC
// Create datetimes with explicit timezones
$utc = new \DateTimeZone('UTC');
$jakarta = new \DateTimeZone('Asia/Jakarta'); // WIB — UTC+7
$bali = new \DateTimeZone('Asia/Makassar'); // WITA — UTC+8
$papua = new \DateTimeZone('Asia/Jayapura'); // WIT — UTC+9
// The current time in various zones
$nowUtc = new \DateTimeImmutable('now', $utc);
$nowJakarta = new \DateTimeImmutable('now', $jakarta);
echo $nowUtc->format('H:i T'); // 07:00 UTC
echo $nowJakarta->format('H:i T'); // 14:00 WIB
// Conversion between timezones
$eventUtc = new \DateTimeImmutable('2024-03-15 07:00:00', $utc);
$eventJakarta = $eventUtc->setTimezone($jakarta);
$eventBali = $eventUtc->setTimezone($bali);
echo $eventJakarta->format('Y-m-d H:i T'); // 2024-03-15 14:00 WIB
echo $eventBali->format('Y-m-d H:i T'); // 2024-03-15 15:00 WITA
// The correct workflow: accept input → convert to UTC → store
function saveEvent(string $localDate, string $timezone): string
{
$tz = new \DateTimeZone($timezone);
$local = new \DateTimeImmutable($localDate, $tz);
$utc = $local->setTimezone(new \DateTimeZone('UTC'));
return $utc->format('Y-m-d H:i:s'); // store this in the database
}
function displayEvent(string $utcDate, string $timezone): string
{
$utc = new \DateTimeImmutable($utcDate, new \DateTimeZone('UTC'));
$local = $utc->setTimezone(new \DateTimeZone($timezone));
return $local->format('d/m/Y H:i T'); // display this to the user
}
$stored = saveEvent('2024-03-15 14:00:00', 'Asia/Jakarta');
echo $stored; // 2024-03-15 07:00:00 (UTC)
echo displayEvent($stored, 'Asia/Jakarta'); // 15/03/2024 14:00 WIB
echo displayEvent($stored, 'Asia/Makassar'); // 15/03/2024 15:00 WITA
echo displayEvent($stored, 'America/New_York'); // 15/03/2024 03:00 EDT
Indonesian Timezone List #
<?php
$indonesianTimezones = [
'Asia/Jakarta' => 'WIB (UTC+7) — Sumatra, Java, West & Central Kalimantan',
'Asia/Makassar' => 'WITA (UTC+8) — Bali, NTB, NTT, South/East/North Kalimantan, Sulawesi',
'Asia/Jayapura' => 'WIT (UTC+9) — Papua & Maluku',
];
foreach ($indonesianTimezones as $tz => $description) {
$dt = new \DateTimeImmutable('now', new \DateTimeZone($tz));
echo $dt->format('H:i') . " $tz — $description\n";
}
The Legacy date() and time() Functions
#
PHP also has older procedural functions. They’re still widely used in older code:
<?php
// time() — the current Unix timestamp (seconds since 1970-01-01 00:00:00 UTC)
$timestamp = time(); // e.g., 1710504000
// date() — format a timestamp into a string
echo date('Y-m-d H:i:s'); // current local time
echo date('Y-m-d H:i:s', $timestamp); // from a specific timestamp
// mktime() — create a timestamp from time components
$ts = mktime(14, 30, 0, 3, 15, 2024); // hour, minute, second, month, day, year
echo date('Y-m-d H:i:s', $ts); // 2024-03-15 14:30:00
// strtotime() — parse a time string into a timestamp
$ts2 = strtotime('2024-03-15');
$ts3 = strtotime('+1 day');
$ts4 = strtotime('next Monday');
$ts5 = strtotime('2024-01-15 +1 month');
// Conversion between timestamps and DateTime
$dt = new \DateTimeImmutable('@' . time()); // timestamp → DateTime
$ts = (new \DateTimeImmutable())->getTimestamp(); // DateTime → timestamp
// checkdate() — date validation
var_dump(checkdate(2, 29, 2024)); // true — 2024 is a leap year
var_dump(checkdate(2, 29, 2023)); // false — 2023 isn't a leap year
var_dump(checkdate(13, 1, 2024)); // false — month 13 doesn't exist
Common Traps When Working with Time #
<?php
// ✗ Trap 1: comparing a datetime with a string directly
$dt = new \DateTimeImmutable('2024-03-15');
$string = '2024-03-15';
// This does NOT work as expected:
var_dump($dt == $string); // false — can't be compared directly
// CORRECT: compare object to object, or string to string
var_dump($dt->format('Y-m-d') === $string); // true
var_dump($dt === new \DateTimeImmutable('2024-03-15')); // false — different instances
var_dump($dt == new \DateTimeImmutable('2024-03-15')); // true — equal values
// ✗ Trap 2: forgetting the timezone when parsing
// This uses PHP's default timezone, which may not be what you intended
$dt1 = new \DateTimeImmutable('2024-03-15 14:00:00');
// CORRECT: always be explicit about the timezone
$dt2 = new \DateTimeImmutable('2024-03-15 14:00:00', new \DateTimeZone('Asia/Jakarta'));
// ✗ Trap 3: storing local timestamps in the database
// When the server changes timezone or DST kicks in, all data becomes wrong
$localTime = date('Y-m-d H:i:s'); // ANTI-PATTERN: local time to the DB
// CORRECT: always store UTC
$utcTime = gmdate('Y-m-d H:i:s'); // gmdate() is always UTC
// Or:
$utcTime = (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s');
// ✗ Trap 4: +1 month on the last day of a month
$endOfJan = new \DateTimeImmutable('2024-01-31');
echo $endOfJan->modify('+1 month')->format('Y-m-d'); // 2024-03-02 — not February!
// CORRECT: use first day of next month when you want the start of a month
echo $endOfJan->modify('first day of next month')->format('Y-m-d'); // 2024-02-01
// ✗ Trap 5: assuming every year has 365 days
// Leap years have 366 days, which affects calculations
$start2024 = new \DateTimeImmutable('2024-01-01');
$start2025 = new \DateTimeImmutable('2025-01-01');
echo $start2024->diff($start2025)->days; // 366 — 2024 is a leap year!
Summary #
DateTimeImmutableis safer thanDateTime— every operation produces a new object, no hidden mutation. Make it the default choice for all new code.createFromFormat()for user input — safer than the constructor because an explicit format prevents date interpretation ambiguity. Always catchfalseas the return value when parsing fails.- UTC for storage, local for display — store all times in UTC in the database, convert to the user’s timezone only when displaying on screen.
DateIntervalwith ISO 8601 notation —P1Y2M3DT4H5M6Sfor complex durations.P1D= 1 day,PT1H= 1 hour,P1W= 1 week.- The
+1 monthtrap at the end of a month causes overflow into the next month. Use'first day of next month'for reliable monthly navigation.DatePeriodfor iterating date ranges — far cleaner than manual loops with day increments. Useful for calendars, periodic reports, and schedule slots.diff()returns aDateInterval— use the->y,->m,->dproperties for separate components, or->daysfor the total days. The->invertproperty is 1 when the first date is greater.gmdate()orsetTimezone(UTC)to get UTC time from procedural functions or before storing in the database.