09 · Working with Dates¶
Dates look simple until you have to add days across a month boundary, compare
two timestamps in different time zones, or format one for display. PHP's
DateTime family (DateTime, DateTimeImmutable, DateInterval,
DateTimeZone) handles all of that correctly — far more reliably than
hand-rolling date math with raw Unix timestamps and 86400-seconds-per-day
assumptions (which break on daylight saving transitions).
Creating dates¶
<?php
$now = new DateTime(); // right now, server's default timezone
$specific = new DateTime("2026-03-15 14:30:00"); // a specific moment
$fromNow = new DateTime("+3 days"); // PHP parses relative expressions too
echo $now->format("Y-m-d H:i:s") . "\n";
echo $specific->format("Y-m-d H:i:s") . "\n"; // 2026-03-15 14:30:00
echo $fromNow->format("Y-m-d") . "\n";
new DateTime("...") accepts a wide range of English-language date formats
("next Monday", "first day of next month", "-2 weeks") in addition to
ISO-style strings — convenient, but for anything parsed from user input or
an API, prefer the strict parsing shown below instead of relying on this
guesswork.
DateTime vs DateTimeImmutable: a common trap¶
DateTime objects are mutable — methods like modify() and add()
change the object in place and also return it, which is easy to misuse when
you only meant to compute a new value.
<?php
$original = new DateTime("2026-01-01");
$modified = $original->modify("+1 month");
// SURPRISE: both variables point at the SAME object -- modify() changed
// $original in place and returned the same instance, it did NOT create a
// separate "one month later" copy.
echo $original->format("Y-m-d") . "\n"; // 2026-02-01 -- not "2026-01-01"!
echo $modified->format("Y-m-d") . "\n"; // 2026-02-01
<?php
// DateTimeImmutable avoids the trap entirely: every "modifying" method
// returns a NEW instance and leaves the original untouched.
$original = new DateTimeImmutable("2026-01-01");
$modified = $original->modify("+1 month");
echo $original->format("Y-m-d") . "\n"; // 2026-01-01 -- unchanged
echo $modified->format("Y-m-d") . "\n"; // 2026-02-01 -- a separate object
Prefer DateTimeImmutable by default in new code — it behaves the way most
people already assume DateTime works, and eliminates a class of bugs where
a date gets silently changed somewhere deep in a function that only looked
like it was reading the value.
Formatting dates¶
<?php
$date = new DateTimeImmutable("2026-07-04 09:05:00");
echo $date->format("Y-m-d") . "\n"; // 2026-07-04
echo $date->format("d/m/Y") . "\n"; // 04/07/2026
echo $date->format("D, d M Y") . "\n"; // Sat, 04 Jul 2026
echo $date->format("H:i:s") . "\n"; // 09:05:00
echo $date->format("g:i A") . "\n"; // 9:05 AM
echo $date->format(DATE_ATOM) . "\n"; // 2026-07-04T09:05:00+00:00 -- ISO 8601, good for APIs
| Format char | Meaning | Example |
|---|---|---|
Y |
4-digit year | 2026 |
m |
2-digit month | 07 |
d |
2-digit day | 04 |
H |
24-hour hour | 09 |
g |
12-hour hour, no leading zero | 9 |
i |
minutes | 05 |
s |
seconds | 00 |
D |
short day name | Sat |
M |
short month name | Jul |
A |
AM/PM | AM |
Parsing strings strictly with createFromFormat¶
When the input format is known and fixed (a CSV column, a specific API
field), createFromFormat() parses exactly that shape and fails loudly on
anything else — safer than the loose, guessing constructor above.
<?php
$input = "15/03/2026"; // day/month/year -- ambiguous without a declared format
$date = DateTimeImmutable::createFromFormat("d/m/Y", $input);
if ($date === false) {
echo "Could not parse date: $input\n";
} else {
echo $date->format("Y-m-d") . "\n"; // 2026-03-15
}
// Passing the same digits with the WRONG format silently misreads
// day/month instead of failing -- this is why declaring the format matters:
$wrong = DateTimeImmutable::createFromFormat("m/d/Y", $input);
// Read as month=15, day=03, year=2026. Month 15 doesn't exist, so PHP
// normalizes by overflowing: 3 months past December rolls into next year.
echo $wrong->format("Y-m-d") . "\n"; // 2027-03-03 -- silently wrong, not an error
Date arithmetic with DateInterval¶
<?php
$start = new DateTimeImmutable("2026-01-31");
$plusOneMonth = $start->add(new DateInterval("P1M")); // "Period of 1 Month"
echo $plusOneMonth->format("Y-m-d") . "\n"; // 2026-03-03 -- NOT Feb 31!
$plusTenDays = $start->add(new DateInterval("P10D"));
echo $plusTenDays->format("Y-m-d") . "\n"; // 2026-02-10
$minusOneWeek = $start->sub(new DateInterval("P1W"));
echo $minusOneWeek->format("Y-m-d") . "\n"; // 2026-01-24
Adding "1 month" to January 31st is a well-known trap: February doesn't have 31 days, so PHP overflows into March rather than silently clamping to February 28th. If a calendar feature depends on month-end behavior, check the result explicitly rather than assuming it lands where you expect.
Comparing dates and computing a difference¶
DateTime objects compare directly with PHP's normal comparison operators,
and diff() produces a DateInterval describing the gap between two dates.
<?php
$deadline = new DateTimeImmutable("2026-12-01");
$today = new DateTimeImmutable("2026-08-02");
var_dump($today < $deadline); // bool(true) -- comparison operators just work
$interval = $today->diff($deadline);
echo "{$interval->days} days remaining\n"; // 121 days remaining
echo "{$interval->m} months, {$interval->d} days\n"; // 3 months, 29 days
$interval->days gives the total number of whole days between the two
dates, while $interval->m/$interval->d break the same gap into calendar
months and remaining days — pick whichever matches what you're displaying.
Time zones¶
<?php
$utc = new DateTimeImmutable("2026-06-15 12:00:00", new DateTimeZone("UTC"));
$mumbai = $utc->setTimezone(new DateTimeZone("Asia/Kolkata"));
echo $utc->format("Y-m-d H:i:s T") . "\n"; // 2026-06-15 12:00:00 UTC
echo $mumbai->format("Y-m-d H:i:s T") . "\n"; // 2026-06-15 17:30:00 IST
Always store and compare dates in UTC internally (e.g. in a database), and convert to a specific time zone only when displaying to a particular user — mixing local time zones through your business logic is a reliable source of off-by-one-hour (or off-by-a-day, near midnight) bugs.
Dates cheat sheet¶
| Tool | Purpose |
|---|---|
DateTimeImmutable |
Preferred date/time type — every operation returns a new instance |
DateTime |
Mutable equivalent — modify()/add()/sub() change the object in place |
->format($fmt) |
Render as a string using format characters |
DateTimeImmutable::createFromFormat($fmt, $str) |
Strict parsing of a known input format |
DateInterval |
A span of time (P1M, P10D, P1W) used with add()/sub() |
->diff($other) |
Compute the DateInterval between two dates |
DateTimeZone |
Represents a named time zone ("UTC", "Asia/Kolkata") |
->setTimezone($tz) |
Return the same instant, viewed in a different time zone |
How It Actually Works¶
DateTime and DateTimeImmutable wrap the same underlying C-level timelib datetime structure (an absolute point in time plus timezone data), but they differ in exactly one behavior baked into their compiled method implementations: every mutating method on DateTime (modify(), add(), sub()) changes that internal structure in place and returns $this, while the same-named methods on DateTimeImmutable allocate a brand-new object with the modified structure and leave the original untouched — this is why $a->modify('+1 day') silently mutates $a itself but $b = $a->modify('+1 day') on an immutable instance requires capturing the return value, and it's the single most common bug source this lesson exists to prevent. createFromFormat() runs timelib's format-directive parser strictly against your format string; unlike the loose constructor (which tries many heuristics to guess a date string's meaning), createFromFormat fails explicitly (returning false and populating DateTime::getLastErrors()) the moment the input doesn't match your directives character-for-character, which is exactly why it's the right tool for parsing user-submitted or external date strings you can't otherwise trust. DateInterval arithmetic isn't simple integer math on a timestamp — adding "+1 month" to January 31st goes through calendar-aware date-component arithmetic in timelib that can produce March 3rd (because February has no 31st), a genuine calendar-rule computation, not a fixed offset in seconds.
Exercise¶
Write a function daysUntil(DateTimeImmutable $target, ?DateTimeImmutable
$from = null): int that returns how many whole days remain until $target
(defaulting $from to "now" when not given), using diff(). Then write
isWeekend(DateTimeImmutable $date): bool using $date->format("N") (ISO-8601
day-of-week number, 1 = Monday through 7 = Sunday). Test daysUntil() against
a date in the past (it should return a negative number — check what diff()
actually gives you for a past date and adjust if needed) and isWeekend()
against a known Saturday and a known Tuesday.