01 · THE IDEAREAD → TRY → LEARN
Why dates deserve their own class
A date is more than a string. It has a calendar, a time of day and a timezone, and the same text can mean different moments depending on the zone. PHP represents this with DateTimeImmutable. It does the same job as DateTime, with one important difference: methods that change the value return a new object and leave the original alone.
That is the property this lesson is about. When a date object can never change under you, passing it into a function is safe. You will not wonder whether some other piece of code shifted it by a day.
Creating a date in UTC
The constructor is new DateTimeImmutable(string $datetime = "now", ?DateTimeZone $timezone = null). In lessons and tests, never rely on "now". Pass a fixed string and an explicit DateTimeZone, so the result is the same on every machine.
$date = new DateTimeImmutable('2024-01-31 09:30:00', new DateTimeZone('UTC'));
echo $date->format('Y-m-d H:i:s T'), "\n";2024-01-31 09:30:00 UTC
The format() method turns the object back into text. In the format string, Y is a four-digit year, m the month, d the day, H:i:s the time and T the timezone abbreviation. Format letters are case-sensitive, so m (month) and i (minutes) are not interchangeable with M and I.
Changes return new objects
Methods such as modify(), add(), sub(), setDate() and setTime() do not edit the object they are called on. The PHP 8.4 manual even marks them so that ignoring the return value is flagged. The bug to watch for is calling $date->modify('+1 day'); as a statement and expecting $date to change. It will not.
$start = new DateTimeImmutable('2024-01-31 09:30:00', new DateTimeZone('UTC'));
$next = $start->modify('+1 day');
echo $start->format('Y-m-d'), "\n";
echo $next->format('Y-m-d'), "\n";2024-01-31 2024-02-01
The original stays on 31 January, and the new object holds 1 February. Always assign the result, or chain it into another call.
Calendar arithmetic is not always intuitive
Adding a fixed number of days is predictable. Adding a month is not always, because months have different lengths. If you add one month to 31 January, PHP computes the nonexistent 31 February and lets it overflow into March. In 2024, a leap year, February has 29 days, so the result is 2 March.
$date = new DateTimeImmutable('2024-01-31', new DateTimeZone('UTC'));
$result = [
'plus_days' => $date->add(new DateInterval('P30D'))->format('Y-m-d'),
'plus_month' => $date->add(new DateInterval('P1M'))->format('Y-m-d'),
];
echo json_encode($result), "\n";{"plus_days":"2024-03-01","plus_month":"2024-03-02"}
DateInterval('P30D') means a period of 30 days. P1M means one month. Pick the unit that matches what you really mean: "30 days later" and "the same day next month" are different questions.
Worked example: a safe helper
Suppose you need a helper that takes a Y-m-d string and returns another Y-m-d string some days later. Work through it in steps:
- Build a
DateTimeImmutablefrom the input, with an explicit UTC zone. - Describe the shift as text such as
+3 daysor-1 days. - Call
modify()and keep the returned object. - Format that new object and return the string.
Using sprintf('%+d days', $days) produces a leading sign for both positive and negative values, so one code path handles both directions.
Edge cases and mistakes
- Discarding the result.
$date->modify(...)alone does nothing useful on an immutable object. - Missing timezone. Without a zone argument, the constructor uses the script's default timezone, which depends on configuration. State UTC explicitly.
- Invalid strings. A malformed date string makes the constructor throw an exception, so validate untrusted input before using it.
- Month-end overflow. Adding months to the 29th, 30th or 31st can land in the following month.
- Mixing up formats.
format('d-m-Y')andformat('Y-m-d')give different strings. ChooseY-m-dwhen strings need to sort correctly.
Exercise contract
Write addDaysUtc(string $date, int $days): string. The input is a date in Y-m-d form. Interpret it as midnight UTC, shift it by $days calendar days (the value may be negative or zero), and return the result as a Y-m-d string. Your function must cross month, year and leap-day boundaries correctly. It should return the new date rather than print it.
Recap
DateTimeImmutable gives you a date value that never changes in place. Give it a fixed input and an explicit UTC zone, assign the result of every modification, and be careful with month arithmetic. Those three habits prevent most date bugs in everyday code.
Classes
- Create a date with an explicit UTC timezone
- Return a changed date without mutating the original