DateTimeImmutable vs. DateTime in PHP 8.4: why immutability wins
AI generated
<?php
8.4
PHP · DateTimeImmutable · Value Objects · PHP 8.4
DateTimeImmutable vs. DateTime
why immutability is almost always the right choice

DateTime objects can be changed after creation, and that is exactly what produces hard-to-find bugs in larger codebases. DateTimeImmutable solves this problem structurally by having every method return a new instance instead of changing the existing object. This article shows, with real PHP 8.4 code, how DateTimeImmutable, DateTimeZone and DateInterval work together, and how existing DateTime code can be migrated.

16 min read DateTimeImmutable · DateTimeZone · DateInterval · Value Objects PHP 8.4

1. The core problem: why mutable DateTime objects create bugs

The DateTime class in PHP is mutable at its core: every call to modify(), add(), sub(), setDate() or setTime() changes the object in place and returns $this for convenience, so calls can be chained. That very convenience is the root cause of an entire class of bugs, which only surface once a DateTime object is referenced from more than one place in the code. Passing an object into a function and calling ->modify('+1 day') there does not just change a local copy, it changes the caller's original object too, because PHP always passes objects around by reference handle.

In practice this problem often only shows up under load or in edge cases. A booking system, for example, might store an appointment's start time in a DateTime object and pass it to a helper function that computes the end time from it. If that function computes the end via $start->add($duration), it also mutates the original start time along the way, with nothing at the call site suggesting this could happen. The result: incorrectly stored appointments that are hard to reproduce, because the bug depends on the context in which the object was passed around.

The classic workaround for years was defensive cloning: $copy = clone $original; before any operation that should not actually change the object. That works, but it is easy to forget, hard to enforce, and turns every code review into a hunt for missing clone calls. This exact structural problem is what DateTimeImmutable solves, by making immutability a property of the class instead of a discipline the developer has to maintain.


<?php

declare(strict_types=1);

// PROBLEM: DateTime is mutable, so modify() changes the shared object
function calculateEndTime(DateTime $start, int $minutes): DateTime
{
    // This looks like it returns a new end time, but it actually
    // mutates the $start object the caller still holds a reference to.
    return $start->modify("+{$minutes} minutes");
}

$appointmentStart = new DateTime('2026-07-23 14:00:00');
$appointmentEnd = calculateEndTime($appointmentStart, 45);

echo $appointmentStart->format('H:i'); // 14:45, WRONG! Original was mutated.
echo $appointmentEnd->format('H:i');   // 14:45, same object, same reference

// The defensive workaround before DateTimeImmutable existed:
function calculateEndTimeSafely(DateTime $start, int $minutes): DateTime
{
    $copy = clone $start; // easy to forget, not enforced by the type system
    return $copy->modify("+{$minutes} minutes");
}

2. DateTimeImmutable in detail: API and differences from DateTime

Since PHP 5.5, DateTimeImmutable exists as a sibling class of DateTime. Both implement the same DateTimeInterface and offer nearly identical method names: format(), modify(), add(), sub(), diff(), setDate(), setTime(), setTimezone(). The decisive difference lies in the return value: every method that would change the object in place on DateTime instead returns a completely new instance on DateTimeImmutable. The original object stays untouched no matter how often or from where it is referenced.

This has an immediate consequence for code style: working with DateTimeImmutable means the return value of every method must be captured explicitly, or nothing happens at all. $date->modify('+1 day'); without an assignment is a no-op with DateTimeImmutable, because the new instance is immediately discarded again. That forces code of the form $next = $date->modify('+1 day');, which looks like extra effort at first glance but actually makes every state change in the code visible and traceable.

Because both classes implement DateTimeInterface, function signatures can type-hint against the interface when both variants should be accepted. For new code, though, it is still preferable to type-hint concretely against DateTimeImmutable, because that makes the intent clear: this parameter is guaranteed not to be changed. The interface itself cannot be implemented by userland classes, a deliberate decision made to protect the internal consistency of both built-in implementations.

3. Handling timezones correctly with DateTimeImmutable and DateTimeZone

Timezone bugs are among the most common and hardest to find in any software that deals with dates and times. DateTimeImmutable plays well with DateTimeZone objects here, without any conversion causing unwanted side effects: $date->setTimezone(new DateTimeZone('Europe/Berlin')) returns a new instance in the desired timezone, while the original stays in its original timezone, unchanged. With DateTime, you would need the same caution here as with any other mutating operation.

A proven pattern in production code: timestamps are consistently stored internally in UTC and only converted into the user's timezone right at the output boundary, just before display or dispatch. This avoids the ambiguities created by daylight saving transitions, such as the one hour in autumn that genuinely exists twice. With DateTimeImmutable, this pattern can be implemented safely, because every conversion explicitly creates a new, independent instance and the original UTC value is never accidentally overwritten.

Instead of using global process configuration like date_default_timezone_set(), the timezone should be passed through the code as an explicit DateTimeZone object, ideally via dependency injection. That makes tests deterministic and prevents one request from changing the global timezone for subsequent requests in the same worker process. The available identifiers are provided by DateTimeZone::listIdentifiers(), while fixed UTC offsets like +02:00 should be avoided, because they do not automatically account for daylight saving rules.


<?php

declare(strict_types=1);

final class MeetingScheduler
{
    /**
     * @param DateTimeZone $storageZone Timezone used for internal storage (always UTC in practice)
     */
    public function __construct(
        private readonly DateTimeZone $storageZone = new DateTimeZone('UTC'),
    ) {
    }

    /**
     * Converts a user-facing local time into a stored UTC instant.
     */
    public function toStorage(DateTimeImmutable $localTime): DateTimeImmutable
    {
        // setTimezone() returns a brand-new instance, the argument is untouched
        return $localTime->setTimezone($this->storageZone);
    }

    /**
     * Converts a stored UTC instant into the recipient's local display timezone.
     */
    public function toDisplay(DateTimeImmutable $utcTime, DateTimeZone $displayZone): DateTimeImmutable
    {
        return $utcTime->setTimezone($displayZone);
    }
}

$scheduler = new MeetingScheduler();
$berlinTime = new DateTimeImmutable('2026-10-25 02:30:00', new DateTimeZone('Europe/Berlin'));
$stored = $scheduler->toStorage($berlinTime);

echo $berlinTime->format('Y-m-d H:i:s P'); // still 2026-10-25 02:30:00 +02:00, unchanged
echo $stored->format('Y-m-d H:i:s P');     // 2026-10-25 00:30:00 +00:00

4. Arithmetic: DateInterval, modify(), add(), sub() without side effects

For precise date arithmetic, PHP provides the DateInterval class, which represents time spans in the ISO 8601 duration format, for example P1Y2M10D for one year, two months and ten days, or PT30M for thirty minutes. Alternatively, DateInterval::createFromDateString('3 weeks') creates an interval from a natural language description. Combined with DateTimeImmutable, add() and sub() can be chained arbitrarily without intermediate states overwriting each other, because every call produces its own, independent instance.

That allows chaining like $date->add($oneWeek)->sub($twoDays)->setTime(9, 0), where each intermediate step can be stored and reused separately if needed, without a later step retroactively changing an earlier stored intermediate value. With DateTime, that would be exactly the risk: storing an intermediate step in a variable and then calling another method on the same object reference also changes the already stored intermediate value retroactively.

For business logic like "add three business days," a simple add() is not enough, because weekends and holidays need to be skipped. A loop that calls add(new DateInterval('P1D')) per iteration and checks the result against a business day works well here. Because DateTimeImmutable returns a new instance at every step, intermediate results can safely be collected in an array or logged, without their value changing afterward.


<?php

declare(strict_types=1);

/**
 * Adds a number of business days (Mon-Fri) to a date, skipping weekends.
 *
 * @param DateTimeImmutable $start The starting point, never mutated
 * @param int $businessDays Number of business days to add
 * @return DateTimeImmutable The resulting date
 */
function addBusinessDays(DateTimeImmutable $start, int $businessDays): DateTimeImmutable
{
    $oneDay = new DateInterval('P1D');
    $current = $start;
    $added = 0;

    while ($added < $businessDays) {
        $current = $current->add($oneDay); // returns a fresh instance every time
        $isWeekend = (int) $current->format('N') >= 6;

        if (!$isWeekend) {
            $added++;
        }
    }

    return $current;
}

$orderDate = new DateTimeImmutable('2026-07-23'); // a Thursday
$shippingDeadline = addBusinessDays($orderDate, 3);

echo $orderDate->format('Y-m-d (D)');        // 2026-07-23 (Thu), untouched
echo $shippingDeadline->format('Y-m-d (D)'); // 2026-07-28 (Tue), Fri, Mon, Tue counted

// Chaining without side effects between intermediate steps
$deadline = $orderDate
    ->add(new DateInterval('P1W'))
    ->sub(new DateInterval('P2D'))
    ->setTime(9, 0);

5. Comparing and sorting date values

Objects that implement DateTimeInterface support the built-in comparison operators <, >, == and the spaceship operator <=> directly, without needing to call getTimestamp() first. PHP compares the internal instant, not the textual timezone, so two objects in different timezones are correctly recognized as equal if they represent the same absolute moment. That holds for DateTimeImmutable just as much as for DateTime, because the comparison works through the shared interface.

When sorting arrays with usort() and the spaceship operator, there is a practical difference: sorting an array of DateTime objects carries the theoretical risk that a comparison function accidentally mutates one of the objects, for example because the callback function accidentally calls modify() instead of a pure comparison. With DateTimeImmutable, that risk is structurally ruled out, because the callback function cannot change the object at all, even if it tries.

For range checks, such as "does this point in time fall between the start and end of an offer," a simple comparison with >= and <= on the DateTimeImmutable instances is sufficient. What matters is consistently comparing within the same timezone or relying on the objects' automatic UTC-based comparison, rather than comparing formatted strings like format('Y-m-d') textually, which can lead to wrong results across different timezones.

6. Formatting and parsing: format(), createFromFormat(), ISO 8601

The format() method is identical between DateTimeImmutable and DateTime and accepts the same format characters, such as Y-m-d for a date or c, or the constant DATE_ATOM, for ISO 8601-compliant timestamps. Since format() merely returns a string and performs no mutation, behavior does not differ between the two classes here; the difference only appears with methods that would otherwise change state.

When parsing input strings, the static method DateTimeImmutable::createFromFormat() returns a new instance from an arbitrary format, or false if the string does not match the given format. A common mistake is passing the return value along without checking it: since createFromFormat() returns false instead of throwing an exception, a missing check quickly leads to a fatal error somewhere completely different in the code, once a method is later called on a bool.

For data exchange between systems, for example in REST APIs, ISO 8601 has become the standard format. $date->format(DATE_ATOM) returns a timestamp like 2026-07-23T14:00:00+02:00, and the constructor of DateTimeImmutable accepts exactly this format without an additional createFromFormat(), because it understands ISO 8601 strings directly. That makes round trips between serialization and deserialization particularly robust.


<?php

declare(strict_types=1);

/**
 * Parses a user-supplied date string in German format (d.m.Y) into DateTimeImmutable.
 *
 * @param string $input Raw input, e.g. "23.07.2026"
 * @return DateTimeImmutable
 * @throws InvalidArgumentException If the input does not match the expected format
 */
function parseGermanDate(string $input): DateTimeImmutable
{
    $parsed = DateTimeImmutable::createFromFormat('!d.m.Y', $input);

    // createFromFormat() returns false on failure, never an exception,
    // so this check must never be skipped.
    if ($parsed === false) {
        throw new InvalidArgumentException("Invalid date format: {$input}");
    }

    return $parsed;
}

$dueDate = parseGermanDate('23.07.2026');
echo $dueDate->format(DATE_ATOM); // 2026-07-23T00:00:00+02:00

// Round-trip through ISO 8601, the constructor understands it directly
$isoString = $dueDate->format(DATE_ATOM);
$roundTripped = new DateTimeImmutable($isoString);

var_dump($dueDate == $roundTripped); // true, same instant

7. Value objects and immutability as a design principle

Immutability is one of the central principles in the design of value objects: a value object should not be able to change its internal state after construction, because two instances with identical values should be considered equal, no matter where they are passed around in the code. DateTimeImmutable is the most prominent built-in example of this principle in PHP, and is therefore an excellent building block for custom value objects that represent points in time or time spans.

PHP 8.4 supports readonly properties and constructor property promotion, which combine naturally with DateTimeImmutable properties: a value object like PriceValidity or AppointmentSlot can declare its DateTimeImmutable fields as readonly, so that even an accidental assignment attempt from outside results in an error at compile time or runtime. Methods that express a change, such as withExtendedEnd(), then consistently return a new instance of the value object instead of changing their own state.

The practical benefit shows up when passing such objects through different layers of an application: a value object with DateTimeImmutable properties can be safely stored in arrays, collections or caches and shared across module boundaries without needing a defensive copy. That not only reduces boilerplate code, it also makes reasoning about the code easier, because an object once created is guaranteed to represent the same value forever.


<?php

declare(strict_types=1);

/**
 * Immutable value object representing a validity window for a price.
 */
final readonly class PriceValidity
{
    /**
     * @param DateTimeImmutable $validFrom Start of the validity window
     * @param DateTimeImmutable $validUntil End of the validity window (exclusive)
     */
    public function __construct(
        private DateTimeImmutable $validFrom,
        private DateTimeImmutable $validUntil,
    ) {
        if ($this->validFrom >= $this->validUntil) {
            throw new InvalidArgumentException('validFrom must be before validUntil');
        }
    }

    /**
     * Checks whether the given instant falls within the validity window.
     */
    public function isActiveAt(DateTimeImmutable $moment): bool
    {
        return $moment >= $this->validFrom && $moment < $this->validUntil;
    }

    /**
     * Returns a new PriceValidity with an extended end date, original stays untouched.
     */
    public function withExtendedEnd(DateInterval $extension): self
    {
        return new self($this->validFrom, $this->validUntil->add($extension));
    }
}

$validity = new PriceValidity(
    validFrom: new DateTimeImmutable('2026-08-01'),
    validUntil: new DateTimeImmutable('2026-09-01'),
);

$extended = $validity->withExtendedEnd(new DateInterval('P2W'));
// $validity itself is completely unchanged, $extended is a distinct object

8. Migrating existing DateTime code to DateTimeImmutable

Converting an existing project all at once is rarely practical, which is why a gradual migration at the system's boundaries works well. Repositories, API clients and external libraries that return DateTime objects get converted to DateTimeImmutable right at their return point, for example with DateTimeImmutable::createFromMutable($legacyDate). That way, the mutable variant gradually moves toward the edges of the application, while the application core works exclusively with the immutable variant.

Extra caution is needed with ORMs and older libraries whose type declarations concretely expect or return DateTime instead of the interface. Such boundaries should be explicitly marked as conversion points, ideally with a small wrapper or mapper that bundles the conversion in a single place rather than spreading it across the entire codebase. With declare(strict_types=1) and typed parameters, places where DateTime is still accidentally expected instead of DateTimeImmutable surface at the very first test run.

Static analysis helps secure the migration: a PHPStan rule or plain code review discipline can prevent new usages of DateTime in the application core and thereby avoid creeping regressions. It makes sense to carry out the migration module by module instead of in a single large refactoring, because that way each change can be tested in isolation and the risk of a large-scale regression drops.

9. When DateTime still makes sense

Despite the clear advantages of DateTimeImmutable, there are a few, but real, exceptions. In extremely performance-critical, tightly written loops with millions of iterations, object creation on every step can noticeably add up, because DateTimeImmutable allocates a completely new instance on every operation, while DateTime keeps reusing the same object. Before reaching for DateTime for this reason, though, it should actually be measured, because the difference is not relevant in the vast majority of use cases, and the robustness of DateTimeImmutable outweighs a micro-optimization potential that rarely materializes.

The second exception concerns legacy libraries and some older APIs that type-hint concretely against DateTime instead of DateTimeInterface, and thereby force a mutable instance at their boundary. In such cases, converting at the edge with DateTime::createFromInterface() in the required direction, and back with DateTimeImmutable::createFromMutable(), is preferable to aligning the entire codebase with the outdated API. For new code, DateTimeImmutable remains the right default decision in virtually every case.

Aspect DateTime DateTimeImmutable Benefit
Calling modify() changes the object in place, returns $this returns a new object, original stays the same no hidden side effects with shared references
Passing to functions function can unintentionally change the original function cannot change the original safe to pass around without defensive clone()
Use in value objects requires clone() on every getter fits directly as a readonly property less boilerplate, clearer semantics
Chaining every method further mutates the same object every method yields a new snapshot intermediate states remain traceable
Caching and reuse cached object can be changed later cached object is guaranteed to stay unchanged safe to store in arrays, collections, caches

10. Summary

Mutable DateTime objects create bugs that depend on object references and are therefore hard to reproduce, as soon as an object is passed to more than one place in the code. DateTimeImmutable solves this problem structurally: every method that would mutate a DateTime instead returns a new instance, while the original is guaranteed to stay unchanged. This holds equally for timezone conversion with DateTimeZone, for arithmetic with DateInterval, for comparisons with the built-in operators, and for formatting and parsing via format() and createFromFormat().

For value objects, DateTimeImmutable is practically the natural choice, because PHP 8.4's readonly properties and constructor property promotion support exactly this immutability principle at the class level. Existing DateTime code can be migrated step by step at the system's boundaries instead of in a risky big-bang refactoring. Only in tight, provably performance-critical loops, or at boundaries to legacy APIs that concretely expect DateTime, does the mutable variant remain a justified exception. For any new code, DateTimeImmutable is the default decision.

DateTimeImmutable vs. DateTime: the essentials at a glance

Core problem

DateTime is mutable: modify(), add() and sub() change the shared object and create reference-dependent bugs.

Solution

DateTimeImmutable returns a new instance on every change, the original is guaranteed to stay unchanged.

Value objects

DateTimeImmutable pairs naturally with readonly properties and constructor property promotion in PHP 8.4.

Migration

Convert gradually at the system boundaries with createFromMutable(), instead of a big-bang refactoring.

11. FAQ: DateTimeImmutable vs. DateTime

1What is the main difference between DateTime and DateTimeImmutable?
DateTime changes itself with modify(), add() or sub() and returns itself. DateTimeImmutable leaves the original unchanged and returns a new instance instead.
2Is DateTimeImmutable slower than DateTime?
Slightly more object creation, but barely measurable in practice. The safety against hidden side effects clearly outweighs it.
3How do I convert DateTime to DateTimeImmutable?
With DateTimeImmutable::createFromMutable($dateTimeObject). The reverse path runs through DateTime::createFromInterface().
4Can I mix DateTimeImmutable and DateTime?
Technically yes via DateTimeInterface, but it is recommended to use DateTimeImmutable consistently in the core and DateTime only at clear conversion boundaries.
5Why does modify() return a new object?
Because immutability is the central property of DateTimeImmutable. The return value must therefore always be explicitly assigned.
6How do I compare two DateTimeImmutable objects?
Directly with <, >, == or <=>. PHP compares the internal instant regardless of the textual timezone.
7How do I handle timezones?
Store internally in UTC, convert only at output with setTimezone(), pass the timezone explicitly as a DateTimeZone object.
8What is DateInterval?
A time span in the ISO 8601 duration format, e.g. P1D. Usable with add() and sub() on DateTimeImmutable, every call returns a new instance.
9Should I use DateTimeImmutable in value objects?
Yes, it fits ideally with readonly properties and constructor property promotion in PHP 8.4, without any additional clone().
10Are there cases where DateTime remains useful?
Only in provably performance-critical loops or at boundaries to legacy APIs that concretely expect DateTime. For new code, DateTimeImmutable remains the standard.

Mironsoft

PHP development with clean, maintainable code

Found date and time bugs in your existing codebase?

We audit existing PHP code for mutable DateTime pitfalls, migrate it step by step to DateTimeImmutable, and build value objects that are immutable from the start.

Code audit

Targeted search for mutable DateTime pitfalls and shared object references

Migration

Gradual transition to DateTimeImmutable at clear system boundaries

Value objects

Design and implementation of immutable domain objects in PHP 8.4