Skip to content

Compare time value objects by value, never by instance. - #20

Merged
gustavofreze merged 1 commit into
mainfrom
fix/structural-equality-of-time-value-objects
Aug 25, 2026
Merged

Compare time value objects by value, never by instance.#20
gustavofreze merged 1 commit into
mainfrom
fix/structural-equality-of-time-value-objects

Conversation

@gustavofreze

Copy link
Copy Markdown
Member

Instant and LocalDate declared the value-object contract while holding a DateTimeImmutable, and structural equality compares a property that is not itself a value object with ===, which on an object is identity. Two instances of the same moment were never equal, their hash codes never matched, and neither did any value object that carried one as a property. Duration had the same defect through Internal\Seconds, and Timezone did not declare the contract at all.

Overriding equals would not have been enough: ValueObjectEquality walks the properties by reflection and never calls equals, so a wrapper would keep comparing the inner DateTimeImmutable by identity. The state is what had to change, so Instant and LocalDate now hold their canonical text, and IsoMoment and IsoDate own the conversion in Internal, where the style rule allows the helpers.

Fixing LocalDate surfaced a second defect. Its parser filled the time of day from the clock of the moment it ran, so two instances of the same date built a second apart reported isAfter and isBefore as true at the same time. Holding the canonical date removes the wall clock along with the identity comparison.

Both createFromFormat calls are gone with it, which removed one PHPStan suppression instead of adding another.

Two notes for consumers. Timezone gains equals and hashCode, which is new public surface. toDateTimeImmutable now returns a new object on every call rather than the stored one, so identity comparisons of its return value change; value comparisons and every other behaviour are unchanged.

Please follow the contributing guidelines.

Summary

What this pull request does.

Related issue

Closes #...

Checklist

  • Tests added or updated.
  • Documentation updated when applicable.
  • make review passes.
  • make tests passes.

Instant and LocalDate declared the value-object contract while holding a
DateTimeImmutable, and structural equality compares a property that is not itself
a value object with ===, which on an object is identity. Two instances of the same
moment were never equal, their hash codes never matched, and neither did any value
object that carried one as a property. Duration had the same defect through
Internal\Seconds, and Timezone did not declare the contract at all.

Overriding equals would not have been enough: ValueObjectEquality walks the
properties by reflection and never calls equals, so a wrapper would keep comparing
the inner DateTimeImmutable by identity. The state is what had to change, so Instant
and LocalDate now hold their canonical text, and IsoMoment and IsoDate own the
conversion in Internal, where the style rule allows the helpers.

Fixing LocalDate surfaced a second defect. Its parser filled the time of day from
the clock of the moment it ran, so two instances of the same date built a second
apart reported isAfter and isBefore as true at the same time. Holding the canonical
date removes the wall clock along with the identity comparison.

Both createFromFormat calls are gone with it, which removed one PHPStan suppression
instead of adding another.

Two notes for consumers. Timezone gains equals and hashCode, which is new public
surface. toDateTimeImmutable now returns a new object on every call rather than the
stored one, so identity comparisons of its return value change; value comparisons
and every other behaviour are unchanged.
@gustavofreze
gustavofreze merged commit 007e01c into main Aug 25, 2026
5 checks passed
@gustavofreze
gustavofreze deleted the fix/structural-equality-of-time-value-objects branch August 25, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant