Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

# Ubuntu 26.04 no longer installs legacy time zone names such as CET and Asia/Rangoon, which the tests use
- name: Install legacy time zones
run: sudo apt-get update && sudo apt-get install -y tzdata-legacy

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
Expand Down
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,33 @@
💥 **Breaking changes**

- `ZonedDateTime::parse()` now throws a `DateTimeException` when the offset is not valid for the region, instead of silently replacing it (#140 by @gnutix)
- `Clock::getTime()` has been renamed to `getInstant()`
- `Clock` has a new method, `atTimeZone(TimeZone): ZonedClock`: custom `Clock` implementations must implement it
- `DefaultClock` has been removed: inject a `Clock` or `ZonedClock` instead, and use `FixedClock`, `OffsetClock` or `ScaleClock` in tests
- All `now()` methods have been removed, use a `ZonedClock` instead:
- `ZonedDateTime::now()` → `ZonedClock::getZonedDateTime()`
- `LocalDateTime::now()` → `ZonedClock::getLocalDateTime()`
- `LocalDate::now()` → `ZonedClock::getLocalDate()`
- `LocalTime::now()` → `ZonedClock::getLocalTime()`
- `YearMonth::now()` → `ZonedClock::getLocalDate()->getYearMonth()`
- `YearWeek::now()` → `ZonedClock::getLocalDate()->getYearWeek()`
- `Quarter::now()` → `ZonedClock::getLocalDate()->getQuarter()`
- `DayOfWeek::now()` → `ZonedClock::getLocalDate()->getDayOfWeek()`
- `MonthDay::now()` → `ZonedClock::getLocalDate()->getMonthDay()`
- `Year::now()` → `Year::of(ZonedClock::getLocalDate()->getYear())`
- `Instant::now()` → `Clock::getInstant()`
- `isFuture()` and `isPast()` have been removed from `Instant`, `LocalDateTime` and `ZonedDateTime`: compare with `isAfter()` / `isBefore()` instead, e.g. `$instant->isAfter($clock->getInstant())`
- `Stopwatch`'s constructor now requires a `Clock`
- `FixedClock::setTime()` has been renamed to `setInstant()`

✨ **New features**

- New class `ZonedClock`, which wraps a `Clock` and a `TimeZone`:
- `getZonedDateTime()`, `getLocalDateTime()`, `getLocalDate()` and `getLocalTime()` return the current date and time in its time zone
- `atTimeZone()` returns a copy in another time zone
- `ZonedClock::fixed(ZonedDateTime)` returns a clock fixed at the given date-time, in its time zone
- All built-in clocks can be bound to a time zone with `atTimeZone()`, e.g. `(new SystemClock())->atTimeZone(TimeZone::utc())`
- New method `Instant::parse()`, which parses an ISO 8601 date-time with an offset, such as `2026-10-10T08:00:00Z`

🐛 **Bug fixes**

Expand Down
115 changes: 61 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,113 +79,120 @@ These classes belong to the `Brick\DateTime` namespace.

### Clocks

All objects read the current time from a `Clock` implementation. The following implementations are available:
The current time is read from a `Clock` implementation. The following implementations are available:

- `SystemClock` returns the system time; it's the default clock
- `SystemClock` returns the system time
- `FixedClock`: returns a pre-configured time
- `OffsetClock`: adds an offset to another clock
- `ScaleClock`: makes another clock fast-forward by a scale factor

These classes belong to the `Brick\DateTime\Clock` namespace.

In your application, you will most likely never touch the defaults, and always use the default clock:
A `Clock` only provides the current `Instant`, and knows nothing about time zones:

```php
use Brick\DateTime\LocalDate;
use Brick\DateTime\TimeZone;
use Brick\DateTime\Clock\SystemClock;

$clock = new SystemClock();

echo LocalDate::now(TimeZone::utc()); // 2017-10-04
echo $clock->getInstant(); // 2017-10-04T12:03:25.123456Z
```

In your tests however, you might need to set the current time to test your application in known conditions. To do this, you can either explicitly pass a `Clock` instance to `now()` methods:
To get the current date or time, bind a clock to a time zone with `atTimeZone()`, which returns a `ZonedClock`:

```php
use Brick\DateTime\Clock\FixedClock;
use Brick\DateTime\Instant;
use Brick\DateTime\LocalDate;
use Brick\DateTime\Clock\SystemClock;
use Brick\DateTime\TimeZone;

$clock = new FixedClock(Instant::of(1000000000));
echo LocalDate::now(TimeZone::utc(), $clock); // 2001-09-09
$clock = (new SystemClock())->atTimeZone(TimeZone::parse('Europe/Paris'));

echo $clock->getZonedDateTime(); // 2017-10-04T14:03:25.123456+02:00[Europe/Paris]
echo $clock->getLocalDateTime(); // 2017-10-04T14:03:25.123456
echo $clock->getLocalDate(); // 2017-10-04
echo $clock->getLocalTime(); // 14:03:25.123456
```

Or you can change the *default* clock for all date-time classes. All methods such as `now()`, unless provided with an explicit Clock, will use the default clock you provide:
Other values are one hop away from a `LocalDate`:

```php
use Brick\DateTime\Clock\FixedClock;
use Brick\DateTime\DefaultClock;
use Brick\DateTime\Instant;
use Brick\DateTime\LocalDate;
use Brick\DateTime\TimeZone;
$date = $clock->getLocalDate();

$date->getYearMonth();
$date->getYearWeek();
$date->getMonthDay();
$date->getQuarter();
$date->getDayOfWeek();
```

The time zone is always explicit: there is no default clock, and the system time zone is never used implicitly.

DefaultClock::set(new FixedClock(Instant::of(1000000000)));
echo LocalDate::now(TimeZone::utc()); // 2001-09-09
This is useful in applications that deal with a single time zone: inject a `ZonedClock` in your services, and configure its time zone once. Applications that deal with multiple time zones can inject a `Clock` instead, and bind it to a time zone every time:

DefaultClock::reset(); // do not forget to reset the clock to the system clock!
```php
$this->zonedClock->getLocalDate(); // single time zone
$this->clock->atTimeZone($timeZone)->getLocalDate(); // multiple time zones
```

There are also useful shortcut methods to use clocks in your tests, inspired by [timecop](https://github.com/travisjeffery/timecop):
#### Testing

- `freeze()` freezes time to a specific point in time
- `travelTo()` travels to an `Instant` in time, but allows time to continue moving forward from there
- `travelBy()` travels in time by a `Duration`, which may be forward (positive) or backward (negative)
- `scale()` makes time move at a given pace
In your tests, you might need to set the current time to test your application in known conditions. To do this, inject one of the test clocks instead of a `SystemClock`; there is no need to fake the `ZonedClock` itself.

#### Freeze the time to a specific point
Freeze the time to a specific point:

```php
use Brick\DateTime\DefaultClock;
use Brick\DateTime\Clock\FixedClock;
use Brick\DateTime\Instant;
use Brick\DateTime\TimeZone;

$clock = (new FixedClock(Instant::parse('2001-09-09T01:46:40Z')))->atTimeZone(TimeZone::utc());

DefaultClock::freeze(Instant::of(2000000000));
echo $clock->getLocalDate(); // 2001-09-09
```

If you don't need to move the clock, `ZonedClock::fixed()` is a shortcut that creates the `FixedClock` for you, from a `ZonedDateTime`:

$a = Instant::now(); sleep(1);
$b = Instant::now();
```php
use Brick\DateTime\ZonedClock;
use Brick\DateTime\ZonedDateTime;

echo $a, PHP_EOL; // 2033-05-18T03:33:20Z
echo $b, PHP_EOL; // 2033-05-18T03:33:20Z
$clock = ZonedClock::fixed(ZonedDateTime::parse('2026-10-10T08:00+01:00[Europe/London]'));

DefaultClock::reset();
echo $clock->getLocalDateTime(); // 2026-10-10T08:00
```

#### Travel to a specific point in time
Travel to a specific point in time, but allow time to continue moving forward from there:

```php
use Brick\DateTime\DefaultClock;
use Brick\DateTime\Clock\OffsetClock;
use Brick\DateTime\Clock\SystemClock;
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;

DefaultClock::travelTo(Instant::of(2000000000));
$a = Instant::now(); sleep(1);
$b = Instant::now();
$systemClock = new SystemClock();
$offset = Duration::between($systemClock->getInstant(), Instant::of(2000000000));
$clock = new OffsetClock($systemClock, $offset);

$a = $clock->getInstant(); sleep(1);
$b = $clock->getInstant();

echo $a, PHP_EOL; // 2033-05-18T03:33:20.000342Z
echo $b, PHP_EOL; // 2033-05-18T03:33:21.000606Z

DefaultClock::reset();
```

#### Make time move at a given pace
Make time move at a given pace:

```php
use Brick\DateTime\DefaultClock;
use Brick\DateTime\Instant;
use Brick\DateTime\Clock\ScaleClock;

DefaultClock::travelTo(Instant::of(2000000000));
DefaultClock::scale(60); // 1 second becomes 60 seconds
$clock = new ScaleClock($clock, 60); // 1 second becomes 60 seconds

$a = Instant::now(); sleep(1);
$b = Instant::now();
$a = $clock->getInstant(); sleep(1);
$b = $clock->getInstant();

echo $a, PHP_EOL; // 2033-05-18T03:33:20.00188Z
echo $b, PHP_EOL; // 2033-05-18T03:34:20.06632Z

DefaultClock::reset();
```

As you can see, you can even combine `travelTo()` and `scale()` methods.

Be very careful to **`reset()` the DefaultClock after each of your tests!** If you're using PHPUnit, a good place to do this is in the `tearDown()` method.

### Exceptions

The following exceptions can be thrown:
Expand Down
14 changes: 12 additions & 2 deletions src/Clock.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@

namespace Brick\DateTime;

/**
* A source of the current instant, independent of any time zone.
*
* To get the current date or time in a time zone, call atTimeZone().
*/
interface Clock
{
/**
* Returns the current time.
* Returns the current instant.
*/
public function getTime(): Instant;
public function getInstant(): Instant;

/**
* Returns a clock that reads the current time from this clock, in the given time zone.
*/
public function atTimeZone(TimeZone $timeZone): ZonedClock;
}
12 changes: 10 additions & 2 deletions src/Clock/FixedClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Brick\DateTime\Clock;
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;
use Brick\DateTime\TimeZone;
use Brick\DateTime\ZonedClock;
use Override;

/**
Expand All @@ -23,12 +25,18 @@ public function __construct(
}

#[Override]
public function getTime(): Instant
public function getInstant(): Instant
{
return $this->instant;
}

public function setTime(Instant $instant): void
#[Override]
public function atTimeZone(TimeZone $timeZone): ZonedClock
{
return new ZonedClock($this, $timeZone);
}

public function setInstant(Instant $instant): void
{
$this->instant = $instant;
}
Expand Down
12 changes: 10 additions & 2 deletions src/Clock/OffsetClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Brick\DateTime\Clock;
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;
use Brick\DateTime\TimeZone;
use Brick\DateTime\ZonedClock;
use Override;

/**
Expand All @@ -25,8 +27,14 @@ public function __construct(
}

#[Override]
public function getTime(): Instant
public function getInstant(): Instant
{
return $this->referenceClock->getTime()->plus($this->offset);
return $this->referenceClock->getInstant()->plus($this->offset);
}

#[Override]
public function atTimeZone(TimeZone $timeZone): ZonedClock
{
return new ZonedClock($this, $timeZone);
}
}
14 changes: 11 additions & 3 deletions src/Clock/ScaleClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Brick\DateTime\Clock;
use Brick\DateTime\Duration;
use Brick\DateTime\Instant;
use Brick\DateTime\TimeZone;
use Brick\DateTime\ZonedClock;
use Override;

/**
Expand All @@ -32,15 +34,21 @@ public function __construct(
private Clock $referenceClock,
private int $timeScale,
) {
$this->startTime = $this->referenceClock->getTime();
$this->startTime = $this->referenceClock->getInstant();
}

#[Override]
public function getTime(): Instant
public function getInstant(): Instant
{
$duration = Duration::between($this->startTime, $this->referenceClock->getTime());
$duration = Duration::between($this->startTime, $this->referenceClock->getInstant());
$duration = $duration->multipliedBy($this->timeScale);

return $this->startTime->plus($duration);
}

#[Override]
public function atTimeZone(TimeZone $timeZone): ZonedClock
{
return new ZonedClock($this, $timeZone);
}
}
12 changes: 10 additions & 2 deletions src/Clock/SystemClock.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@

use Brick\DateTime\Clock;
use Brick\DateTime\Instant;
use Brick\DateTime\TimeZone;
use Brick\DateTime\ZonedClock;

/**
* This clock returns the system time. It is the default clock.
* This clock returns the system time.
*
* This clock has a microsecond precision on most systems.
*/
final class SystemClock implements Clock
{
#[\Override]
public function getTime(): Instant
public function getInstant(): Instant
{
[$fraction, $epochSecond] = \explode(' ', microtime());

Expand All @@ -24,4 +26,10 @@ public function getTime(): Instant

return Instant::of($epochSecond, $nanoAdjustment);
}

#[\Override]
public function atTimeZone(TimeZone $timeZone): ZonedClock
{
return new ZonedClock($this, $timeZone);
}
}
10 changes: 0 additions & 10 deletions src/DayOfWeek.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,6 @@ enum DayOfWeek: int implements JsonSerializable
case SATURDAY = 6;
case SUNDAY = 7;

/**
* Returns the current day-of-week in the given time-zone, according to the given clock.
*
* If no clock is provided, the system clock is used.
*/
public static function now(TimeZone $timeZone, ?Clock $clock = null): DayOfWeek
{
return LocalDate::now($timeZone, $clock)->getDayOfWeek();
}

/**
* Returns the seven days of the week in an array.
*
Expand Down
Loading
Loading