Working with DateTime and DateTimeImmutable

Create and parse date/time values with DateTime and DateTimeImmutable, format them for output, compare and modify them, work with DateInterval, and attach timezones with DateTimeZone.

Almost every real application eventually has to answer questions like "how many days until this order ships" or "what time is it for this user". PHP's native date/time API — DateTime, DateTimeImmutable, DateTimeZone, and DateInterval — gives you an object-oriented way to represent a moment in time, parse it from strings, format it back out, compare two moments, and shift them forward or backward, all while correctly handling calendar oddities like leap years and month lengths that hand-rolled timestamp arithmetic gets wrong. This lesson builds up the whole toolkit, and pays close attention to the single biggest gotcha in the API: the difference between the mutable DateTime class and its immutable sibling, DateTimeImmutable.

Creating and Parsing Dates

The simplest way to get a date/time object is new DateTime('now') (or the shorthand new DateTime(), since 'now' is the default), which captures the current moment. Both DateTime and DateTimeImmutable also accept a huge range of human-readable strings in their constructor, powered by PHP's built-in date parser: '2024-03-15', '2024-03-15 14:30:00', '15 March 2024', 'next Monday', '+3 days', and 'tomorrow' are all valid. This flexible parsing is convenient for quick scripts, but it's also a trap for anything user-facing or format-sensitive, because the parser has to guess at ambiguous formats.

When you know the exact format you're receiving — from a form field, an API, or a legacy database — use the static factory method DateTime::createFromFormat() (or DateTimeImmutable::createFromFormat()) instead of the loose constructor. It takes a format string using the same format characters as date()/format(), so DateTime::createFromFormat('d/m/Y', '15/03/2024') unambiguously parses that string as day-first, no matter what the current locale or the parser's heuristics would otherwise guess. This is the safest way to turn an untrusted or externally-defined string into a date object.

Both constructors and createFromFormat() return false (or throw, depending on PHP version and strictness) when parsing fails on malformed input, so production code should always check the result before using it — don't assume a string from a form or query parameter is well-formed just because your test data was.

• new DateTime('now') — current date/time; new DateTime() defaults to 'now' as well. • The constructor accepts flexible relative and absolute strings: '2024-03-15', 'next Monday', '+3 days'. • DateTime::createFromFormat($format, $string) parses a string against an exact, known format — always prefer this for untrusted input. • Always validate the return value of parsing calls; malformed input can produce false instead of throwing in some contexts.

DateTime vs DateTimeImmutable

DateTime is mutable: calling a method like modify(), add(), or sub() on it changes the object in place and returns $this. That sounds convenient, but it means if you pass a DateTime object to a function, or share it between two variables, a change made in one place silently changes it everywhere else it's referenced — a classic source of hard-to-trace bugs, especially once objects get passed around between methods, stored in arrays, or held as properties.

DateTimeImmutable solves this by never modifying itself. Every mutating-looking method — modify(), add(), sub(), setDate(), setTime(), setTimezone() — returns a brand new DateTimeImmutable instance with the change applied, leaving the original completely untouched. This matches how PHP's other value-like types behave (strings, integers) and eliminates an entire category of aliasing bugs. Because of this, modern PHP code should default to DateTimeImmutable unless there's a specific, deliberate reason to want in-place mutation (for example, incrementally advancing a single loop counter where cloning would be wasteful).

Both classes share the exact same formatting, comparison, and read-only API (format(), getTimestamp(), diff(), comparison operators) — the only difference is what happens when you call a method that changes the represented date. You can convert between them at any time with DateTime::createFromImmutable() and DateTimeImmutable::createFromMutable().

• DateTime: mutating methods change the object in place and return $this. • DateTimeImmutable: mutating-looking methods return a new object; the original is untouched. • Default to DateTimeImmutable in new code to avoid shared-reference mutation bugs. • Convert between them with DateTime::createFromImmutable() / DateTimeImmutable::createFromMutable().

Formatting Output

The format() method turns a DateTime/DateTimeImmutable object into a string using a format character mini-language shared with the procedural date() function. Common characters: Y (4-digit year), m (2-digit month), d (2-digit day), H/i/s (24-hour hour/minute/second), D/l (short/full weekday name), M/F (short/full month name), and T/e/P (timezone abbreviation, name, and UTC offset respectively). DATE_ATOM (equivalent to the format string 'Y-m-d\TH:i:sP') produces ISO 8601 output, which is the recommended format for anything that crosses a system boundary — APIs, logs, storage.

Because format() is just a read operation, it works identically on DateTime and DateTimeImmutable and never changes the underlying object, so you can call it as many times as you like with different format strings without any risk.

• format('Y-m-d') → e.g. 2024-03-15; format('Y-m-d H:i:s') → full timestamp. • format(DATE_ATOM) or format('c') → ISO 8601, the safest interchange format. • format('l, F j, Y') → a human-readable string like Friday, March 15, 2024. • format() never mutates the object — safe to call repeatedly with different formats.

Comparison, Modification, and Intervals

DateTime and DateTimeImmutable both implement comparison in a way that lets you use PHP's ordinary comparison operators (<, >, <=, >=, ==) directly on two date objects, and PHP compares them by the moment in time they represent, not by object identity. This makes sorting and range checks read naturally: if ($orderDate < $cutoff) just works.

For arithmetic, modify() takes a relative-format string (the same kind the constructor accepts, like '+1 day' or 'next Friday'), while add() and sub() take a DateInterval object built either from its constructor's ISO 8601 duration spec (e.g. new DateInterval('P1D') for one day, 'P1Y2M3D' for 1 year 2 months 3 days, 'PT1H30M' for 1 hour 30 minutes) or from the convenient static helper DateInterval::createFromDateString('3 weeks'). Interval-based arithmetic correctly accounts for real calendar rules — adding a month to January 31st rolls over sensibly rather than just adding a fixed number of seconds, and it also respects leap years and variable month lengths.

To find the difference between two dates, use diff(), which returns a DateInterval describing the gap between them broken into years, months, days, hours, minutes, and seconds (plus a days property giving the total whole days), rather than forcing you to do manual timestamp subtraction and division.

• Comparison operators (<, >, ==) work directly on DateTime/DateTimeImmutable objects. • modify('+1 month') — relative string modification. • add(new DateInterval('P1D')) / sub(new DateInterval('P1D')) — calendar-aware interval arithmetic. • $a->diff($b) returns a DateInterval with y, m, d, h, i, s, and total days.

Timezones with DateTimeZone

Every DateTime/DateTimeImmutable instance carries an associated DateTimeZone. You can set it explicitly as a second constructor argument — new DateTimeImmutable('now', new DateTimeZone('America/New_York')) — and read or change it later with getTimezone() and setTimezone() (which, on DateTimeImmutable, returns a new object representing the same instant but displayed in the new zone). If no timezone is given, PHP uses the script's default timezone, which is why production code should always call date_default_timezone_set() explicitly (commonly to 'UTC') at bootstrap rather than relying on php.ini defaults that can differ between environments.

Timezone identifiers should always be IANA names like 'America/New_York' or 'Europe/London' rather than fixed abbreviations like 'EST', because named zones carry the full history of daylight-saving and offset-change rules for that region — a topic covered in depth in the next lesson.

• new DateTimeZone('Asia/Tokyo') — pass as the constructor's second argument. • ->setTimezone(new DateTimeZone('UTC')) — reinterpret display timezone without changing the instant. • Always set an explicit default timezone at bootstrap: date_default_timezone_set('UTC'). • Prefer IANA zone names over fixed abbreviations for DST correctness.

Core DateTime API

createFromFormat() returns false on failure — always check before use. add()/sub() on DateTimeImmutable return a new instance; the original is untouched. diff() always returns a positive DateInterval unless you pass absolute: false.

$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$parsed = DateTimeImmutable::createFromFormat('d/m/Y', '15/03/2024');
$formatted = $now->format(DATE_ATOM);
$later = $now->add(new DateInterval('P1D'));
$diff = $later->diff($now);
$isAfter = $later > $now;

Code Examples

Parses an unambiguous day/month/year string with createFromFormat(), formats it two different ways, then compares it against another DateTimeImmutable using a plain '<' operator — no manual timestamp math required. Expected result: 2024-03-15 Friday, March 15, 2024 Launch has already happened. format() can be called as many times as needed on the same object with different format strings because it never mutates the object it's called on.

if ($launch < $today) { Demonstrates calendar-aware interval math (note how adding one month to Jan 31 overflows into March because February has no 31st day), the createFromDateString() helper, and reading the total day count out of a diff() result. Expected result: 2024-03-02 2024-02-21 21 days apart This overflow behavior is a well-known PHP DateInterval quirk worth remembering: adding 'P1M' to a date near the end of a month can jump further than expected. When you need strict end-of-month semantics, check the day-of-month after adding and clamp manually.

$oneMonthLater = $start->add(new DateInterval('P1M'));

setTimezone() doesn't change the instant in time being represented — all three variables refer to the exact same moment — it only changes how that moment is displayed when formatted, including automatically applying the correct DST offset for New York in July. Expected result: 2024-07-04 18:00:00 UTC 2024-07-04 14:00:00 EDT 2024-07-05 03:00:00 JST

$newYork = $utc->setTimezone(new DateTimeZone('America/New_York'));

<?php
$launch = DateTimeImmutable::createFromFormat('d/m/Y', '15/03/2024');
if ($launch === false) {
    throw new InvalidArgumentException('Bad date format');
}

echo $launch->format('Y-m-d') . PHP_EOL;      // 2024-03-15
echo $launch->format('l, F j, Y') . PHP_EOL;   // Friday, March 15, 2024

$today = new DateTimeImmutable('2024-03-20');
if ($launch < $today) {
    echo "Launch has already happened." . PHP_EOL;
}
<?php
$start = new DateTimeImmutable('2024-01-31', new DateTimeZone('UTC'));

$oneMonthLater = $start->add(new DateInterval('P1M'));
echo $oneMonthLater->format('Y-m-d') . PHP_EOL; // 2024-03-02 (Jan 31 + 1 month rolls into March, Feb has no 31st)

$threeWeeksLater = $start->add(DateInterval::createFromDateString('3 weeks'));
echo $threeWeeksLater->format('Y-m-d') . PHP_EOL; // 2024-02-21

$diff = $threeWeeksLater->diff($start);
echo $diff->days . ' days apart' . PHP_EOL; // 21 days apart
<?php
$utc = new DateTimeImmutable('2024-07-04 18:00:00', new DateTimeZone('UTC'));

$newYork = $utc->setTimezone(new DateTimeZone('America/New_York'));
$tokyo   = $utc->setTimezone(new DateTimeZone('Asia/Tokyo'));

echo $utc->format('Y-m-d H:i:s T') . PHP_EOL;     // 2024-07-04 18:00:00 UTC
echo $newYork->format('Y-m-d H:i:s T') . PHP_EOL; // 2024-07-04 14:00:00 EDT
echo $tokyo->format('Y-m-d H:i:s T') . PHP_EOL;   // 2024-07-05 03:00:00 JST

DateTime vs DateTimeImmutable mutation

Assigning a DateTime does not clone it — both variables reference the same object, so mutating methods affect every reference.

DateTime (mutable)                DateTimeImmutable
  -------------------                ------------------
  $a = new DateTime(...)             $a = new DateTimeImmutable(...)
  $b = $a;   // same object!         $b = $a;   // same object!
  $a->modify('+1 day');              $c = $a->modify('+1 day');

  $b is ALSO now +1 day               $a is unchanged
  (both point at the same             $b is unchanged
   mutated object)                    $c is the NEW, shifted value

Important Rules

Prefer DateTimeImmutable in new code; reach for DateTime only when in-place mutation is a deliberate, contained choice.

Parse untrusted or externally-sourced strings with createFromFormat(), not the loose constructor.

Always check parsing results for false before using them.

Use IANA timezone names ('America/New_York'), never fixed abbreviations, when precision matters.

Use format(DATE_ATOM) / ISO 8601 for any date crossing a system boundary.

Scheduling systems and expiring tokens

Booking platforms, subscription billing, and auth-token expiry all live or die on correct date arithmetic. A calendar app computing 'one month from today' relies on DateInterval's calendar-aware rollover behavior rather than naively adding 30*86400 seconds (which drifts against real months). An API issuing short-lived access tokens stores an absolute DateTimeImmutable expiry, computed once at issue time with add(new DateInterval('PT15M')), and every subsequent request just compares 'now' against that stored instant — no mutable shared state, no surprises from another part of the code accidentally advancing the expiry.

function extendDeadline(DateTime $deadline): void { $deadline->modify('+7 days'); // mutates the caller's object! } $deadline = new DateTime('2024-06-01'); extendDeadline($deadline); // $deadline is now silently 2024-06-08 everywhere it's referenced function extendDeadline(DateTimeImmutable $deadline): DateTimeImmutable { return $deadline->modify('+7 days'); } $deadline = new DateTimeImmutable('2024-06-01'); $deadline = extendDeadline($deadline); // explicit reassignment, original untouched elsewhere

Passing a mutable DateTime into a function lets that function silently change every other reference to the same object (a stored property, another array entry, a cached value) — a bug that's easy to introduce and painful to trace. DateTimeImmutable forces the change to be explicit: the function must return the new value and the caller must reassign it.

What is the key behavioral difference between DateTime and DateTimeImmutable? A. DateTimeImmutable cannot represent times, only dates B. DateTime's mutating methods change the object in place; DateTimeImmutable's return a new object and leave the original untouched C. DateTimeImmutable is slower and should be avoided in production D. They are identical; DateTimeImmutable is just a deprecated alias

B. DateTime's mutating methods change the object in place; DateTimeImmutable's return a new object and leave the original untouched — DateTime methods like modify()/add()/sub() mutate the object in place and return $this; the equivalent DateTimeImmutable methods return a brand-new object, leaving the original unchanged — this avoids shared-reference mutation bugs.

Which method should you use to parse a string in a known, exact format like '15/03/2024' (day/month/year)? A. new DateTime('15/03/2024') B. strtotime('15/03/2024') C. DateTime::createFromFormat('d/m/Y', '15/03/2024') D. date('d/m/Y', '15/03/2024')

C. DateTime::createFromFormat('d/m/Y', '15/03/2024') — createFromFormat() parses a string against an explicit, unambiguous format string, avoiding the guessing that the loose constructor or strtotime() would otherwise do on an ambiguous format.

What does $a->diff($b) return? A. An integer number of seconds B. A DateInterval describing the gap between the two dates, broken into years/months/days/etc. C. A boolean indicating whether $a is before $b D. A new DateTime representing the midpoint

B. A DateInterval describing the gap between the two dates, broken into years/months/days/etc. — diff() returns a DateInterval object with y/m/d/h/i/s components plus a total 'days' property, rather than a raw number, so you can display 'X months and Y days' style output easily.

Write a function daysUntil(DateTimeImmutable $target, DateTimeImmutable $from): int that returns the whole number of days between $from and $target (positive if $target is in the future). Use diff() rather than manual timestamp subtraction, and make sure the function does not mutate either argument.

function daysUntil(DateTimeImmutable $target, DateTimeImmutable $from): int { $interval = $from->diff($target); return $interval->invert ? -$interval->days: $interval->days; } diff() returns a DateInterval with a days property (total whole days) and an invert property (1 if the interval is negative).