Skip to content

Speed up ZonedDateTime conversions, and support years beyond 9999 - #135

Merged
BenMorel merged 6 commits into
brick:mainfrom
gnutix:feature/local-date-time-to-epoch-second
Oct 8, 2026
Merged

BenMorel merged 6 commits into
brick:mainfrom
gnutix:feature/local-date-time-to-epoch-second

Conversation

@gnutix

@gnutix gnutix commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ZonedDateTime::of(), ofInstant() and toNativeDateTime() convert through strings and \DateTime. This is slow,
and breaks outside years 0000–9999:

LocalDateTime::of(10000, 1, 1)->atTimeZone(TimeZone::utc());
// DateMalformedStringException: Failed to parse time string (10000-01-01T00:00) at position 11 (T): Double time specification

$zdt = LocalDateTime::max()->atTimeZone(TimeZone::utc());
$zdt->getEpochSecond(); // 946684799 instead of 31494784780799
(string) $zdt;          // 1999-12-31T23:59:59.999999999Z

toNativeDateTime() also fails for negative years and years beyond 9999, and returns the wrong instant for the first
occurrence of an ambiguous time:

$zdt = ZonedDateTime::ofInstant(Instant::of(1729989000), TimeZone::parse('Europe/Paris'));
(string) $zdt;                               // 2024-10-27T02:30:00+02:00[Europe/Paris]
$zdt->toNativeDateTime()->format(DATE_ATOM); // 2024-10-27T02:30:00+01:00, one hour later

Change

No new public API, and no change to the supported year range.

  • of():
    • offsets: the instant is computed from toEpochDay(), toSecondOfDay() and the offset, without \DateTime;
    • regions: still resolved by \DateTime, so gaps and overlaps are unchanged. Years beyond 9999 get the
      ISO 8601 + sign that \DateTime requires, and the local date-time is no longer re-parsed: in a gap, it is
      shifted with plusSeconds().
  • ofInstant(): the local date-time is computed from the instant and the offset (TimeZone::getOffset() for regions),
    instead of formatting and re-parsing a \DateTime.
  • toNativeDateTime(): built from the instant (createFromFormat('U.u', ...)), then the time zone is set.
  • TimeZoneOffset::toNativeDateTimeZone(): the DateTimeZone for Z is cached and cloned. new DateTimeZone('Z') takes
    ~8 µs, as PHP looks it up among abbreviations, against ~0.1 µs for other ids or a clone.

Verification against main

  • of(): identical on 1.1M date-times (every region and random offsets, years -9999 to 9999, plus every DST
    transition 1970–2040 in 12 regions, probed every 15 minutes ±26 hours).
  • ofInstant(): identical on 154k instants (every region and random offsets, all years, every transition since 1900
    in 6 regions). Instants outside the LocalDate range still throw a DateTimeException; for Instant::min() and
    max() it is now "Invalid year" instead of a DateTimeParseException on a wrapped-around year.
  • toNativeDateTime(): identical timestamp, format, time zone and serialize() output on 128k date-times, except
    1,056 overlap cases where main returned the wrong timestamp.
  • Years beyond 9999 and negative years: 300k random date-times round-trip exactly between of() and ofInstant();
    far-year gaps and overlaps behave as in 2030.

Tests

  • providerOf(): offsets at years ±999999, -1, 0, 9999 and 10000, an offset with seconds; regions with unusual
    transitions (Apia skipping a day, Lord Howe 30-minute DST, São Paulo gap at midnight, LMT offsets in seconds),
    the 9999 → 10000 boundary, min() / max(), and Paris in year 10000 with its gap and overlap (expected values
    derived from the EU rule).
  • ofInstant(): edge cases (offsets with seconds, ±18:00, gap and overlap edges, far years) and out-of-range instants.
  • toNativeDateTime(): far years, and both occurrences of an overlap.
  • toNativeDateTimeZone(): returns a new instance on each call.

Benchmark

PHP 8.5, best of 7 runs over 1000 values:

Operation Before After
LocalDateTime::atTimeZone(UTC) 15.5 µs 0.9 µs
LocalDateTime::atTimeZone('Europe/Paris') 8.5 µs 4.3 µs
Instant::atTimeZone(UTC) 13.9 µs 1.5 µs
Instant::atTimeZone('+02:00') 5.7 µs 1.4 µs
Instant::atTimeZone('Europe/Paris') 6.6 µs 2.7 µs
Instant::__toString() 15.4 µs 2.8 µs
LocalDateTime::toNativeDateTime() 27.4 µs 2.2 µs

Out of scope

These behave the same on main, and are left for separate issues:

  • DST overlaps in with*() / plus*(): they re-resolve the local date-time with of(), which always picks the
    offset closest to UTC, so the first occurrence of an ambiguous time jumps one hour:
    2024-10-27T02:30+02:00[Europe/Paris]->withNano(5) gives 02:30:00.000000005+01:00. plusDays(0),
    plusPeriod() and withTimeZoneSameLocal() do the same. java.time keeps the current offset when it is still valid.
  • ISO output beyond year 9999 has no + sign (10000-01-01), and LocalDate::parse('+10000-01-01') throws.
    ISO 8601 and java.time use +10000-01-01, and JavaScript's Temporal requires +010000.
  • ZonedDateTime::parse() ignores the offset in the text when a region is given:
    2030-10-27T02:30+02:00[Europe/Paris] comes back as +01:00, and an impossible +05:00[Europe/Paris] is accepted.
  • CET, EET, MET, WET, EST, MST and HST are abbreviation-type zones in PHP, so they have no DST.
  • TimeZone::parse('Etc/GMT+12') throws, while TimeZoneRegion::of('Etc/GMT+12') accepts it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe

@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.23%. Comparing base (2cafd91) to head (dbc17cb).

Additional details and impacted files
@@            Coverage Diff            @@
##               main     #135   +/-   ##
=========================================
  Coverage     99.23%   99.23%           
- Complexity     1139     1140    +1     
=========================================
  Files            48       48           
  Lines          2489     2499   +10     
=========================================
+ Hits           2470     2480   +10     
  Misses           19       19           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@BenMorel

BenMorel commented Oct 6, 2026

Copy link
Copy Markdown
Member

Hi @gnutix, I'm not really at ease with adding a method to LocalDateTime that deals with timezones, especially one that only works with one kind of timezone.

I'd rather restrict the min/max year so that it never fails with PHP's DateTime, and improve performance of ZonedDateTime if we can?

@gnutix
gnutix force-pushed the feature/local-date-time-to-epoch-second branch from 8badf88 to 1467602 Compare October 6, 2026 20:56
@gnutix gnutix changed the title Add LocalDateTime::toEpochSecond(), and use it in ZonedDateTime::of() for fixed offsets Speed up ZonedDateTime::of(), and support years beyond 9999 Oct 6, 2026
@gnutix

gnutix commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor Author

PR adapted per your wishes, description updated.

@gnutix
gnutix force-pushed the feature/local-date-time-to-epoch-second branch from 1467602 to f65a63d Compare October 6, 2026 21:05
@gnutix gnutix changed the title Speed up ZonedDateTime::of(), and support years beyond 9999 Speed up ZonedDateTime::of() and toNativeDateTime(), and support years beyond 9999 Oct 6, 2026
gnutix and others added 3 commits October 6, 2026 23:38
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe
@gnutix gnutix changed the title Speed up ZonedDateTime::of() and toNativeDateTime(), and support years beyond 9999 Speed up ZonedDateTime conversions, and support years beyond 9999 Oct 6, 2026
Comment thread src/TimeZoneOffset.php Outdated
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SuH4dkwo3PSoj9VzBkWuxe
@gnutix

gnutix commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@BenMorel done

@BenMorel
BenMorel merged commit 7591b4d into brick:main Oct 8, 2026
9 checks passed
@BenMorel

BenMorel commented Oct 8, 2026

Copy link
Copy Markdown
Member

Thank you, @gnutix!

@BenMorel

BenMorel commented Oct 8, 2026

Copy link
Copy Markdown
Member

Released as 0.10.3.

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.

2 participants