DateTime Class
What Are the DateTime Classes?
In simple terms: PHP's DateTime classes represent dates and times as objects. An object knows its date, time, and time zone, and provides methods to format it, move it forward or backward, compare it with other dates, and calculate differences.
Technically: The date extension provides a family of classes:
| Class | Purpose |
|---|---|
DateTimeImmutable | A date and time that never changes; every modification returns a new object |
DateTime | A date and time that is modified in place |
DateTimeInterface | The interface implemented by both (use it in type declarations) |
DateTimeZone | A time zone, such as Asia/Kolkata |
DateInterval | A duration, such as "1 month and 2 days" |
DatePeriod | A recurring series of dates |
Why Use Objects Instead of Timestamps?
- Time zones are part of the object, so conversions are explicit and correct.
- Month and year arithmetic ("add 1 month") is handled by the calendar, not by counting seconds.
- Differences in years, months, and days are calculated correctly, including leap years and daylight saving time.
- Methods make code readable:
$date->modify('+1 week'),$a->diff($b)->y.
DateTimeImmutable vs DateTime
The two classes have almost identical methods. The difference is what happens when you modify them:
<?php
$start = new DateTime('2026-10-15');
$end = $start->modify('+7 days');
echo "DateTime: start " . $start->format('Y-m-d') . ", end " . $end->format('Y-m-d') . "\n";
$start = new DateTimeImmutable('2026-10-15');
$end = $start->modify('+7 days');
echo "DateTimeImmutable: start " . $start->format('Y-m-d') . ", end " . $end->format('Y-m-d') . "\n";
Output
DateTime: start 2026-10-22, end 2026-10-22
DateTimeImmutable: start 2026-10-15, end 2026-10-22
Why This Matters
With DateTime, modify() changes the object itself and returns it, so $start and $end are the same object — the start date was silently moved. This is a common source of bugs, especially when a date object is passed to a function that "only calculates something" but modifies it.
DateTimeImmutable returns a new object for every change and leaves the original untouched. Use DateTimeImmutable by default; this lesson uses it throughout.
Creating Date Objects
From a Date String
<?php
$tz = new DateTimeZone('Asia/Kolkata');
$a = new DateTimeImmutable('2026-10-15 09:00', $tz);
$b = new DateTimeImmutable('tomorrow', $tz); // relative formats work too
$c = new DateTimeImmutable('@1791541200'); // from a Unix timestamp (always UTC)
echo $a->format('Y-m-d H:i T') . "\n";
echo $c->format('Y-m-d H:i:s P') . "\n";
echo $c->setTimezone($tz)->format('Y-m-d H:i:s P') . "\n";
Output
2026-10-15 09:00 IST
2026-10-09 10:20:00 +00:00
2026-10-09 15:50:00 +05:30
- The second argument sets the time zone; without it, the default time zone is used.
- A string starting with
@is a Unix timestamp. It is always interpreted as UTC (the time zone argument is ignored), andsetTimezone()converts it for display. new DateTimeImmutable()without arguments gives the current date and time.
From a Known Format: createFromFormat()
User input such as 15/10/2026 should be parsed with an explicit format:
<?php
$withTime = DateTimeImmutable::createFromFormat('d/m/Y', '15/10/2026');
$midnight = DateTimeImmutable::createFromFormat('!d/m/Y', '15/10/2026');
echo $midnight->format('Y-m-d H:i:s') . "\n";
var_dump($withTime->format('H:i:s') === '00:00:00');
Output (the second line is false unless you run it exactly at midnight)
2026-10-15 00:00:00
bool(false)
Without !, any fields not in the format (here, the time) are taken from the current time. The ! at the start resets all unspecified fields to zero, which is what you want for dates without times.
Strict Validation of Parsed Dates
createFromFormat() returns false for input that does not match the format, but it accepts impossible dates and rolls them over:
<?php
function parseIndianDate(string $input): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!d/m/Y', $input);
return ($date !== false && $date->format('d/m/Y') === $input) ? $date : null;
}
foreach (['15/10/2026', '31/02/2026', '2026-10-15', '5/10/2026'] as $input) {
$date = parseIndianDate($input);
echo str_pad($input, 12) . ($date?->format('D, d M Y') ?? 'invalid') . "\n";
}
Output
15/10/2026 Thu, 15 Oct 2026
31/02/2026 invalid
2026-10-15 invalid
5/10/2026 invalid
31/02/2026would be accepted bycreateFromFormat()as 3 March 2026 (31 days counted from 1 February). Formatting the result back and comparing it with the input detects this.2026-10-15does not match the format, socreateFromFormat()returnsfalse.5/10/2026is rejected by the round-trip check because the format expects two-digit days; usej/n/Yif single digits should be allowed.
Invalid Date Strings Throw Exceptions
<?php
try {
$date = new DateTimeImmutable('not a date');
} catch (Exception $e) {
echo get_class($e) . ": " . $e->getMessage() . "\n";
}
Output (PHP 8.3+)
DateMalformedStringException: Failed to parse time string (not a date) at position 0 (n): The timezone could not be found in the database
Since PHP 8.3, the constructor throws the more specific DateMalformedStringException (a subclass of Exception); earlier versions throw a plain Exception. Catching Exception works in both. The message refers to time zones because the parser tried to interpret the unknown word as a time zone name.
Changing Dates
modify() With Relative Formats
<?php
$date = new DateTimeImmutable('2026-10-15 09:00');
echo $date->modify('+1 week')->format('Y-m-d') . "\n";
echo $date->modify('next friday')->format('Y-m-d') . "\n";
echo $date->modify('last day of this month')->format('Y-m-d') . "\n";
echo $date->modify('first day of next month')->format('Y-m-d') . "\n";
Output
2026-10-22
2026-10-16
2026-10-31
2026-11-01
modify() accepts the same expressions as strtotime().
add() and sub() With DateInterval
A DateInterval is created from an ISO 8601 duration string: P starts the period, followed by years (Y), months (M), weeks (W), and days (D); T starts the time part with hours (H), minutes (M), and seconds (S).
<?php
$start = new DateTimeImmutable('2026-10-15 09:00');
echo $start->add(new DateInterval('P1M2DT3H'))->format('Y-m-d H:i') . "\n";
echo $start->sub(new DateInterval('PT90M'))->format('Y-m-d H:i') . "\n";
echo $start->add(new DateInterval('P2W'))->format('Y-m-d') . "\n";
Output
2026-11-17 12:00
2026-10-15 07:30
2026-10-29
| Duration String | Meaning |
|---|---|
P1D | 1 day |
P2W | 2 weeks |
P1M2D | 1 month and 2 days |
P1Y | 1 year |
PT2H30M | 2 hours 30 minutes |
PT90M | 90 minutes |
Note that M means months before T and minutes after it.
The End-of-Month Trap
<?php
$date = new DateTimeImmutable('2026-01-31');
echo $date->modify('+1 month')->format('Y-m-d') . "\n";
echo $date->modify('last day of next month')->format('Y-m-d') . "\n";
Output
2026-03-03
2026-02-28
Adding one month to 31 January gives "31 February", which does not exist; PHP rolls over to 3 March (2026 is not a leap year). For monthly billing or reports, use expressions such as last day of next month, or base calculations on a fixed day number that exists in every month.
Comparing Dates
Date objects can be compared directly with comparison operators:
<?php
$deadline = new DateTimeImmutable('2026-10-20 23:59:59', new DateTimeZone('Asia/Kolkata'));
$submitted = new DateTimeImmutable('2026-10-20 18:30:00', new DateTimeZone('UTC'));
var_dump($submitted < $deadline);
var_dump($submitted == $deadline);
Output
bool(false)
bool(false)
The comparison uses the actual moment in time, regardless of time zone. 18:30 UTC on 20 October is midnight (00:00) on 21 October in India — after the deadline, so the submission is late, even though "18:30" looks earlier than "23:59". Never compare dates as formatted strings in different time zones.
Calculating Differences: diff()
$a->diff($b) returns a DateInterval describing the difference between two dates in calendar units:
<?php
$birthDate = new DateTimeImmutable('1998-03-20');
$today = new DateTimeImmutable('2026-10-15');
$age = $birthDate->diff($today);
echo "Age: {$age->y} years\n";
echo $age->format('%y years, %m months, %d days') . "\n";
echo $age->format('Total: %a days') . "\n";
$overdue = $today->diff(new DateTimeImmutable('2026-10-10'));
echo $overdue->format('%R%a days') . "\n";
var_dump($overdue->invert);
Output
Age: 28 years
28 years, 6 months, 25 days
Total: 10436 days
-5 days
int(1)
| Property / Format | Meaning |
|---|---|
->y, ->m, ->d, ->h, ->i, ->s | Years, months, days, hours, minutes, seconds |
->days / %a | Total number of days |
->invert / %R | 1 / - if the second date is earlier |
$birthDate->diff($today)->y is the correct way to calculate an age — it accounts for leap years and whether the birthday has already occurred this year.
Recurring Dates: DatePeriod
<?php
$firstEmi = new DateTimeImmutable('2026-11-05');
$monthly = new DateInterval('P1M');
$schedule = new DatePeriod($firstEmi, $monthly, 5); // start + 5 recurrences
$loanAmount = 60000;
$installments = 6;
$emi = $loanAmount / $installments;
foreach ($schedule as $number => $date) {
printf("EMI %d: %s ₹%s\n", $number + 1, $date->format('D, d M Y'), number_format($emi));
}
Output
EMI 1: Thu, 05 Nov 2026 ₹10,000
EMI 2: Sat, 05 Dec 2026 ₹10,000
EMI 3: Tue, 05 Jan 2027 ₹10,000
EMI 4: Fri, 05 Feb 2027 ₹10,000
EMI 5: Fri, 05 Mar 2027 ₹10,000
EMI 6: Mon, 05 Apr 2027 ₹10,000
How It Works
DatePeriodgenerates dates starting at$firstEmiand repeating every$monthlyinterval.- The third argument is the number of recurrences after the start date, so
5produces six dates in total. Alternatively, pass an end date instead of a number. - When the start date is a
DateTimeImmutable, the generated dates areDateTimeImmutableobjects too.
(This simplified schedule divides the amount evenly without interest; real loan schedules also calculate interest, often with the reducing-balance method.)
Common Mistakes
Using DateTime and Modifying Shared Objects
Effect: Dates change unexpectedly in other parts of the code.
Fix: Use DateTimeImmutable.
Parsing Without ! in createFromFormat()
Effect: Dates carry the current time, so comparisons and equality checks fail.
Fix: Start formats with ! (or |) when no time is given.
Accepting Rolled-Over Dates
Effect: 31/02/2026 is accepted as 3 March.
Fix: Format the parsed date back and compare it with the input.
Adding Months to the 29th–31st
Effect: Dates skip into the following month.
Fix: Use last day of ... expressions or adjust to the end of the month explicitly.
Calculating Ages by Dividing Days by 365
Effect: Off-by-one ages around birthdays and leap years.
Fix: Use diff()->y.
Best Practices
- Use
DateTimeImmutableand type-hint parameters withDateTimeInterface. - Always create dates with an explicit
DateTimeZone. - Parse user input with
createFromFormat('!...')and validate it strictly. - Use
DateIntervalanddiff()for calendar arithmetic instead of counting seconds. - Use
DatePeriodfor schedules and recurring dates.
Official reference: https://www.php.net/manual/en/class.datetimeimmutable.php