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 use DateTimeImmutable as the default choice — its behavior is more predictable and prevents subtle bugs from accidental object mutation. Use DateTime only 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 #

CharacterDescriptionExample Output
Y4-digit year2024
y2-digit year24
m2-digit month03
nMonth without leading zero3
MShort month name (English)Mar
FFull month name (English)March
d2-digit day05
jDay without leading zero5
DShort day name (English)Fri
lFull day name (English)Friday
NDay of week (1=Monday, 7=Sunday)5
wDay of week (0=Sunday, 6=Saturday)5
H24-hour, 2-digit14
G24-hour, no leading zero14
h12-hour, 2-digit02
i2-digit minutes30
s2-digit seconds00
AAM or PMPM
aam or pmpm
UUnix timestamp1710504000
WWeek number of the year (ISO 8601)11
tNumber of days in the month31
LIs it a leap year (1/0)1
ZTimezone offset in seconds25200
PTimezone offset +HH:MM+07:00
OTimezone offset +HHMM+0700
eTimezone nameAsia/Jakarta
cFull ISO 86012024-03-15T14:30:00+07:00
rRFC 2822Fri, 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 month trap — 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-02 because February 2024 only has 29 days. For reliable monthly navigation, always reset to the first day or use first 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 #

  • DateTimeImmutable is safer than DateTime — 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 catch false as 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.
  • DateInterval with ISO 8601 notationP1Y2M3DT4H5M6S for complex durations. P1D = 1 day, PT1H = 1 hour, P1W = 1 week.
  • The +1 month trap at the end of a month causes overflow into the next month. Use 'first day of next month' for reliable monthly navigation.
  • DatePeriod for iterating date ranges — far cleaner than manual loops with day increments. Useful for calendars, periodic reports, and schedule slots.
  • diff() returns a DateInterval — use the ->y, ->m, ->d properties for separate components, or ->days for the total days. The ->invert property is 1 when the first date is greater.
  • gmdate() or setTimezone(UTC) to get UTC time from procedural functions or before storing in the database.

← Previous: Arrays   Next: Regex →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact