midnight / temporal-php
PHP implementation of the TC39 Temporal API
Requires
- php: >=8.4
Requires (Dev)
- carthage-software/mago: ^1.15.3
- infection/infection: ^0.32
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^11.0
- psalm/plugin-phpunit: ^0.19
- vimeo/psalm: ^6.0
This package is auto-updated.
Last update: 2026-03-27 16:33:10 UTC
README
A PHP 8.4 implementation of the TC39 Temporal API.
Temporal is the modern replacement for JavaScript's Date, providing a precise, unambiguous date/time API. This library brings those semantics to PHP with full nanosecond precision, strict types, backed enums, and named arguments.
Requirements
- PHP 8.4+
- Composer
ext-intl(required fortoLocaleString()on spec-layerInstantandZonedDateTime)
Installation
composer require midnight/temporal-php
Architecture
This library has two API tiers:
| Layer | Namespace | Purpose |
|---|---|---|
| Porcelain | Temporal\ |
PHP-idiomatic API with strict types, enums, and named arguments |
| Spec | Temporal\Spec\ |
TC39-faithful implementation, validated by 4600+ test262 scripts |
Use the porcelain layer for application code. The spec layer exists to pass the TC39 conformance suite and is considered internal.
Usage
PlainDate
A calendar date without time or time zone.
use Temporal\PlainDate; use Temporal\Duration; use Temporal\Overflow; use Temporal\Unit; $date = new PlainDate(2024, 3, 15); $date = PlainDate::parse('2024-03-15'); // Read-only fields $date->year; // 2024 $date->month; // 3 $date->day; // 15 // Calendar-derived properties $date->calendarId; // 'iso8601' $date->monthCode; // 'M03' $date->dayOfWeek; // 1-7 (Monday-Sunday) $date->dayOfYear; // 1-366 $date->weekOfYear; // 1-53 $date->yearOfWeek; // ISO week-year $date->daysInMonth; // 28-31 $date->daysInYear; // 365 or 366 $date->inLeapYear; // bool // Arithmetic $later = $date->add(new Duration(days: 30)); $earlier = $date->subtract(new Duration(months: 2)); // Override fields $copy = $date->with(year: 2025); $copy = $date->with(month: 2, overflow: Overflow::Reject); // Comparison PlainDate::compare($a, $b); // -1, 0, or 1 $a->equals($b); // bool // Difference $d = $a->until($b, largestUnit: Unit::Month); $d = $a->since($b, largestUnit: Unit::Year); // Conversions $dt = $date->toPlainDateTime(); // midnight $dt = $date->toPlainDateTime(new PlainTime(9, 30)); // 09:30 $zdt = $date->toZonedDateTime('America/New_York'); $ym = $date->toPlainYearMonth(); $md = $date->toPlainMonthDay(); // Serialization echo $date; // '2024-03-15' echo json_encode($date); // '"2024-03-15"'
PlainTime
A wall-clock time without date or time zone.
use Temporal\PlainTime; use Temporal\Duration; use Temporal\Unit; use Temporal\RoundingMode; $time = new PlainTime(9, 30); $time = PlainTime::parse('09:30:00.123456789'); // Read-only fields $time->hour; // 9 $time->minute; // 30 $time->second; // 0 $time->millisecond; // 0 $time->microsecond; // 0 $time->nanosecond; // 0 // Arithmetic (wraps at midnight) $later = $time->add(new Duration(hours: 2, minutes: 15)); $earlier = $time->subtract(new Duration(minutes: 30)); // Rounding $rounded = $time->round(Unit::Minute); $rounded = $time->round(Unit::Second, roundingMode: RoundingMode::Ceil); // Difference $d = $a->since($b, largestUnit: Unit::Hour); $d = $a->until($b); // Override fields $copy = $time->with(hour: 10, minute: 0); // Serialization echo $time; // '09:30:00'
PlainDateTime
A date and time without time zone.
use Temporal\PlainDateTime; use Temporal\PlainTime; use Temporal\Duration; use Temporal\Disambiguation; $dt = new PlainDateTime(2024, 3, 15, 9, 30); $dt = PlainDateTime::parse('2024-03-15T09:30:00'); // All PlainDate + PlainTime properties available $dt->year; // 2024 $dt->hour; // 9 $dt->dayOfWeek; // 5 (Friday) // Arithmetic $later = $dt->add(new Duration(days: 30, hours: 2)); // Rounding $rounded = $dt->round(Unit::Minute); // Conversions $date = $dt->toPlainDate(); $time = $dt->toPlainTime(); $dt2 = $dt->withPlainTime(new PlainTime(12, 0)); $zdt = $dt->toZonedDateTime('Europe/Berlin', disambiguation: Disambiguation::Earlier, ); // Serialization echo $dt; // '2024-03-15T09:30:00'
Instant
A fixed point in time with nanosecond precision (~1677-2262).
use Temporal\Instant; use Temporal\Duration; use Temporal\Unit; $instant = Instant::parse('2020-01-01T12:00:00Z'); $instant = Instant::fromEpochMilliseconds(1_577_880_000_000); $instant = Instant::fromEpochNanoseconds(1_577_880_000_000_000_000); // Properties $instant->epochNanoseconds; // int $instant->epochMilliseconds; // int // Arithmetic $later = $instant->add(new Duration(hours: 1, minutes: 30)); $diff = $a->since($b, largestUnit: Unit::Hour); // Rounding $rounded = $instant->round(Unit::Minute); // Convert to ZonedDateTime $zdt = $instant->toZonedDateTime('America/New_York'); // Serialization echo $instant; // '2020-01-01T12:00:00Z'
ZonedDateTime
A date and time bound to a specific time zone.
use Temporal\ZonedDateTime; use Temporal\Duration; use Temporal\Disambiguation; use Temporal\OffsetOption; use Temporal\TransitionDirection; $zdt = new ZonedDateTime(epochNanoseconds: 0, timeZoneId: 'UTC'); $zdt = ZonedDateTime::parse( '2024-03-15T09:30:00+01:00[Europe/Berlin]', disambiguation: Disambiguation::Compatible, offset: OffsetOption::Reject, ); // All date/time properties + timezone info $zdt->year; // 2024 $zdt->hour; // 9 $zdt->timeZoneId; // 'Europe/Berlin' $zdt->offset; // '+01:00' $zdt->offsetNanoseconds; // 3600000000000 $zdt->epochNanoseconds; $zdt->epochMilliseconds; $zdt->hoursInDay; // usually 24, varies at DST transitions // Arithmetic (DST-aware) $later = $zdt->add(new Duration(hours: 1)); // Override fields $copy = $zdt->with(hour: 12, disambiguation: Disambiguation::Earlier); // DST transitions $next = $zdt->getTimeZoneTransition(TransitionDirection::Next); $prev = $zdt->getTimeZoneTransition(TransitionDirection::Previous); // Conversions $instant = $zdt->toInstant(); $date = $zdt->toPlainDate(); $time = $zdt->toPlainTime(); $dt = $zdt->toPlainDateTime(); $moved = $zdt->withTimeZone('Asia/Tokyo'); // Serialization echo $zdt; // '2024-03-15T09:30:00+01:00[Europe/Berlin]'
Duration
An ISO 8601 duration with 10 fields, all strict int.
use Temporal\Duration; use Temporal\Unit; use Temporal\RoundingMode; $d = new Duration(years: 1, months: 6, days: 15); $d = Duration::parse('P1Y6M15DT2H30M'); // Properties $d->years; // int (not int|float like the spec layer) $d->sign; // -1, 0, or 1 $d->blank; // true if all fields are zero // Arithmetic $sum = $d->add($other); $diff = $d->subtract($other); // Mutation $neg = $d->negated(); $abs = $d->abs(); $copy = $d->with(years: 2); // Rounding $rounded = $d->round(smallestUnit: Unit::Minute); $rounded = $d->round( largestUnit: Unit::Hour, smallestUnit: Unit::Second, roundingMode: RoundingMode::HalfExpand, ); // Total in a unit $hours = $d->total(Unit::Hour); // int|float $days = $d->total(Unit::Day, relativeTo: new PlainDate(2024, 1, 1), ); // Comparison Duration::compare($a, $b); $a->equals($b); // Serialization echo $d; // 'P1Y6M15DT2H30M'
PlainYearMonth
A year and month without a day.
use Temporal\PlainYearMonth; $ym = new PlainYearMonth(2024, 3); $ym = PlainYearMonth::parse('2024-03'); $ym->year; // 2024 $ym->month; // 3 $ym->daysInMonth; // 31 $ym->inLeapYear; // true $date = $ym->toPlainDate(day: 15);
PlainMonthDay
A month and day without a year (e.g., a birthday or anniversary).
use Temporal\PlainMonthDay; $md = new PlainMonthDay(12, 25); $md = PlainMonthDay::parse('--12-25'); $date = $md->toPlainDate(year: 2024);
Now
Current date and time. Static-only, not instantiable.
use Temporal\Now; $instant = Now::instant(); $tzId = Now::timeZoneId(); // e.g. 'Europe/Amsterdam' $date = Now::plainDate(); // system timezone $date = Now::plainDate('Asia/Tokyo'); // explicit timezone $time = Now::plainTime(); $dt = Now::plainDateTime(); $zdt = Now::zonedDateTime();
Enums
All option strings are replaced by backed enums:
| Enum | Cases |
|---|---|
RoundingMode |
Ceil, Floor, Expand, Trunc, HalfCeil, HalfFloor, HalfExpand, HalfTrunc, HalfEven |
Overflow |
Constrain, Reject |
Unit |
Year, Month, Week, Day, Hour, Minute, Second, Millisecond, Microsecond, Nanosecond |
Disambiguation |
Compatible, Earlier, Later, Reject |
OffsetOption |
Use, Prefer, Ignore, Reject |
CalendarDisplay |
Auto, Always, Never, Critical |
TimeZoneDisplay |
Auto, Never, Critical |
OffsetDisplay |
Auto, Never |
TransitionDirection |
Next, Previous |
Spec-layer interop
Every porcelain class has toSpec() and fromSpec() for dropping to the TC39-faithful layer when needed:
$specDate = $date->toSpec(); // Temporal\Spec\PlainDate $date = PlainDate::fromSpec($spec); // back to porcelain
Development
This project runs in Docker. Start the environment:
docker compose up -d
Then run commands via the php service:
docker compose exec php vendor/bin/phpunit --testsuite porcelain docker compose exec php composer phpstan docker compose exec php composer psalm docker compose exec php composer mago
Or run the full check suite (static analysis + tests + mutation testing):
docker compose exec php composer check
Individual scripts
| Script | Command |
|---|---|
| All tests | composer test |
| Porcelain tests | phpunit --testsuite porcelain |
| test262 conformance | composer test262:run |
| Transpile test262 | composer test262:build |
| Tests + coverage | composer test-coverage |
| PHPStan (level 9) | composer phpstan |
| Psalm (level 1) | composer psalm |
| Mago lint | composer mago |
| Mutation testing | composer infection |
test262 conformance
TC39 maintains test262, the official JavaScript conformance test suite. This project includes a transpiler (tools/transpile-test262.mjs) that converts Temporal test262 JS files to PHP, enabling direct conformance testing against the spec layer.
docker compose exec php composer test262:build docker compose exec php composer test262:run
Currently 4610 test262 tests passing (0 failures, 1135 incomplete due to JS-only features like Symbol, Proxy, and property descriptor access).
Transparency
This codebase is written with Claude Code. All production code and tests are AI-generated.
Quality is enforced by PHPStan (level 9), Psalm (error level 1), Mago, PHPUnit, and Infection with a 100% mutation kill threshold. None of that is negotiable -- every change must pass the full suite before it counts.
License
MIT