midnight/temporal-php

PHP implementation of the TC39 Temporal API

Maintainers

Package info

github.com/MidnightDesign/temporal-php

pkg:composer/midnight/temporal-php

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 2

dev-master 2026-03-27 16:32 UTC

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 for toLocaleString() on spec-layer Instant and ZonedDateTime)

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