bakame / tokei
Immutable value objects for expressive temporal modeling: time, duration, circular 24-hour intervals, and interval sets, without timezone handling.
Fund package maintenance!
Requires
- php: ^8.3.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^12.5 || ^13.1.8
- symfony/var-dumper: ^7.4 || ^8.0
Suggests
- ext-intl: to handle time locale string conversion with the best performance
- symfony/polyfill-intl-icu: to handle time locale string conversion via the Symfony polyfill if ext-intl is not present
README
Tokei (pronounced: [to̞ke̞ː] or [tokeː]) is a lightweight domain-focused set of immutable value objects for representing and operating on time, durations, including, circular 24-hour intervals, and interval sets, offering expressive temporal modeling without timezone handling.
The framework-agnostic package offers a consistent and expressive way to work with temporal values in a safe and predictable manner.
Installation
composer require bakame/tokei
You need:
- PHP >= 8.3 but the latest stable version of PHP is recommended
- to be able to get the locale string version of the time you need the
ext-intlextension or use a polyfill forIntlDateFormatter.
Documentation
Time
The Bakame\Tokei\Time object is designed to be, cyclic (24h wrap-around) and precision-aware (microseconds supported)
Instantiation
You can create a Time instance:
- using its time components via the
Time::atmethod; - by parsing a time string using the
Time::fromFormatmethod; - using
Time::fromOffset; The value will represent respectively a quantity in a specified base Unit from midnight.
Time::at(int $hour = 0, int $minute = 0, int $second = 0, int $microsecond = 0): Time; Time::fromFormat(string $value, TimeFormat $format = TimeFormat::Iso8601): Time Time::fromOffset(int $value, Unit $unit): Time
Here's some usage example.
use Bakame\Tokei\Time; $time = Time::at(hour: 10, minute: 30, second: 15); $time = Time::fromFormat("10:30:15.123456", TimeFormat::Iso8601); $time = Time::fromFormat("10h30m15s123456µs", TimeFormat::Compact); $time = Time::fromOffset(123_456_789, Unit::Microsecond); $time = Time::fromOffset(123_456, Unit::Millisecond); $time = Time::fromOffset(123, Unit::Second); $time = Time::fromOffset(-1, Unit::Minute); // returns "23:59:00"
To ease instantiation, predefined instances can be obtained with the following methods:
Time::midnight(); // 00:00:00 Time::noon(); // 12:00:00 Time::endOfDay(); // 23:59:59.999999 Time::utc(); // the UTC current time Time::now('Africa/Nairobi'); // the current time in Nairobi, Kenya Time::fromDateTime(new DateTimeImmutable()); // returns the extracted time from any DateTimeInterface instance
Note
The timezone is required when using Time::now() to
return the current time in a specific timezone. The method
accepts a DateTimeZone instance or a timezone string identifier.
Once instantiated, the timezone information is lost.
Accessors
Once instantiated you can access each time component using the following methods
$time = Time::fromFormat("10:30:15.123456"); $time->hour; // returns 10 $time->minute; // returns 30 $time->second; // returns 15 $time->microsecond; // returns 123456
Formatting
Time::format(TimeFormat $format = TimeFormat::Iso8601): string Time::toOffset(Unit $unit): float; // returns the time value according to the provided Time::toLocaleString(string $locale, ?DateTimeZone $timezone = null, LocaleVerbosity $verbosity = LocaleVerbosity::Medium): string Time::toDateTime(DateTimeZone|string $timezone): DateTimeImmutable
To work as expected the Time::toLocaleString requires the presence of the Intl extension or
of its polyfill otherwise a TimeException will be thrown.
Example:
$time = Time::at(hour: 10, minute: 30, second: 15, microsecond: 123456); echo $time->format(); // 10:30:15.123456 echo $time->format(TimeFormat::Compact); // 10h30m15s123456µs echo $time->toOffset(Unit::Second); // 37815.123456 echo $time->toLocaleString('en-US'); // 10:30:15 AM echo $time->toLocaleString('de-DE', 'Africa/Nairobi', LocaleVerbosity::Full); // 10:30:15 Ostafrikanische Zeit
Modifying time
Because Time is an immutable VO, any change to its value will return a new instance
with the updated value and leave the original object unchanged. You can modify the time
with the following methods:
Time::shiftwill add a duration to change the time;Time::withwill adjust a specific time component;Time::roundTowill adjust a specific time component;Time::clampwill adjust the time against two other time references;
Time::shift(Duration $duration): Time Time::with(?int $hour = null, ?int $minute = null, ?int $second = null, ?int $microsecond = null): Time Time::roundTo(Unit $unit, RoundingStrategy $strategy = RoundingStrategy::Nearest): Time Time::clamp(Time $min, Time $max): Time
The shift and with methods act differently in regard to wrapping around 24hours automatically.
The Time::shift supports wrapping whereas Time::with does not and instead
throws an InvalidTime exception instead
// adding 2 hours $time = Time::noon()->shift(Duration::of(hours: 2, minutes: 15)); $time->format(); // returns "14:15:00" // adding 12 hours $time = Time::noon()->shift(Duration::of(hours: 12, minutes: 15)); $time->format(); // returns "00:15:00" // setting the hour to $time = Time::noon()->with(hour: 2); $time->format(); // returns "02:15:00" Time::noon()->with(hour: 25); //throws a Bakame\Tokei\InvalidTime exception
To simplify reasoning around time you can also truncate or round its value to one of
the unit declare on the Bakame\Tokei\Unit enum
$t = Time::fromUnitOfDay(3_150_000_000, Unit::Microsecond); $t->format(); // returns "00:52:30" $t->roundTo(Unit::Minutes, RoundingStrategy::Floor)->format(); // returns "00:52:00" $t->roundTo(Unit::Minutes, RoundingStrategy::Nearest)->format(); // returns "00:53:00"
Comparing times
It is possible to compare two Time instances using the Time::compareTo method.
Time::compareTo(Time $other): int;
the method returns:
-1if earlier0if equal1if later
Convenient methods derived from Time::compareTo are also available to ease usage:
$time = Time::at(hour: 10); $other = Time::noon(); $time->isBefore($other); // returns true $time->isAfter($other); // returns false $time->isBeforeOrEqual($other); // returns true $time->isAfterOrEqual($other); // returns false $time->equals($other); // returns false
Differences
The class provides two methods to account for differences between two Time instances:
Time::diff(Time $other): Duration; Time::distance(Time $other): Duration;
- the
Time::diffreturns the signed difference between both instances; - the
Time::distancereturns the forward cyclic difference (24 wrap) between both instances;
Here's an example usage to highlight the distinction in returned values between both differences methods:
$a = Time::at(hour: 23); // 23:00 $b = Time::at(hour: 1); // 01:00 $a->diff($b)->format(DurationFormat::Iso8601); // returns "-PT22H" $a->distance($b)->format(DurationFormat::Iso8601); // returns "PT2H"
Interacting with PHP's native Date API
Time::fromDateTime(DateTimeInterface $datetime): Time Time::toDateTime(DateTimeZone|string $timezone): DateTimeImmutable; Time::applyTo(DateTimeInterface $datetime): DateTimeImmutable;
In one hand, it is possible to extract the time part of any DateTimeInterface
implementing class using the fromDateTime method. On the other hand, you
can apply the time to an DateTimeInterface object using the applyTo method or get
the time attached to current day in a specific timezone using the toDateTime method.
Note
If the DateTimeInterface instance submitted extends the
DateTimeImmutable class then the return type will be of that same type
otherwise PHP's DateTimeImmutable is returned.
use Bakame\Tokei\Time; use Carbon\Carbon; use Carbon\CarbonImmutable; $time = Time::fromDateTime(new DateTime('2025-12-27 23:00', new DateTimeZone('Africa/Nairobi'))); // 23:00 $newDate = $time->applyTo(CarbonImmutable::parse('2025-02-23')); $newDate->format('Y-m-d H:i'); // '2025-02-23 23:00' $newDate->toDateTimeString(); // '2025-02-23 23:00' $newDate::class; // Carbon\CarbonImmutable $altDate = $time->applyTo(Carbon::parse('2025-02-23')); $altDate->format('Y-m-d H:i'); // '2025-02-23 23:00' $altDate::class; // DateTimeImmutable $date2 = $time->toDateTime('Asia/Tokyo'); // DateTimeImmutable // an instance from the current date at 23:00 Tokyo time.
Duration
The Bakame\Tokei\Duration Value Object provides utilities for working with durations
Instantiation
The Duration class can be instantiated either by providing:
- each duration parts using the complementary
Duration::ofmethod. - a ISO8601 duration expression.
use Bakame\Tokei\Duration; $durationA = Duration::of(hours: 2, seconds:59); $durationB = Duration::fromFormat(value: 'P2WT3H', format: DurationFormat::Iso8601); //2 weeks and 3 hours $durationC = Duration::fromDateInterval(new DateInterval('PT23M3S'));
Important
Duration::fromFormat only parse ISO8601 notations with deterministic part (ie: years and months are excluded)
Duration::of only using non-negative integer otherwise and exception will be thrown
$duration = Duration::fromFormat('P2025Y3DT25s', DurationFormat::Iso8601); // throws a Bakame\Tokei\InvalidDuration exception // because of the presence of the Y component
Accessors
Once instantiated you can access the duration properties directly.
The object exposes a sign property which indicates if the original value was negative, 0 or positive.
And provides a toMicro method to get the microseconds based representation of the duration.
$durationB->hours; // returns 1 $durationB->minutes; // returns 1 $durationB->seconds; // returns 1 $durationB->microseconds; // returns 234_000 $durationB->sign; // returns 1 $durationB->daysCount; // returns the absolute number of complete 24-hour days contained in the duration $durationB->weeksCount; // returns the absolute number of complete weeks contained in the duration $durationB->isZero() // returns true when the duration is zero, false otherwise
Formatting
Duration::format(DurationFormat $format = DurationFormat::Iso8601): string Duration::toDateInterval(): DateInterval Duration::total(Unit $unit = Unit::Microseconds): float
Formatting the duration string representation is returned by the Duration::format with the help of the DurationFormat Enum
When using the DurationFormat::Timer the following human-readable format is used:
[-]H:mm:ss[.microseconds]
- microseconds are optional
- negative values are prefixed with
-
When using the DurationFormat::Iso8601 formats the instance value is converted into a ISO8601 compatible string.
The returned string may not be compatible with PHP's DateInterval constructor but is valid withing the ISO8601 extended specification.
$duration = Duration::of(hours: 25, seconds: 5); $duration->format(DurationFormat::Iso8601); // returns 'P1D1H5S' $duration->format(DurationFormat::Timer); // returns '25:00:05'
Important
- Only deterministic duration interval are used
Y,Mfor month are not used - to have a predictive representation
Wis not used;7Dmultiple are used instead.
$duration = Duration::fromFormat('-P2W', DurationFormat::Iso8601); $duration->format(DurationFormat::Iso8601); // returns '-P14D'
Last but not least a compact format more suited for debugging is returns using the DurationFormat::Compact case.
$duration = Duration::of(hours: 25, seconds: 5); $duration->format(DurationFormat::Compact); // returns '1d1h5s'
The Duration class also allows conversion in time units and in DateInterval instances.
The method Duration::toDateInterval converts the instance into a PHP DateInterval
instance while preserving its sign (inverted intervals are supported).
$duration = Duration::of(microseconds: 3_661_234_000); $duration->toDateInterval(); // returns DateInterval $durationB->total(Unit::Microsecond); // returns the full duration in microseconds $durationB->total(Unit::Hours); // returns the full duration in hours
Modifying duration
Duration::abs(): Duration Duration::negated(): Duration Duration::increase(int $weeks = 0, int $days = 0, int $hours = 0, int $minutes = 0, int $seconds = 0, int $microseconds = 0): Duration Duration::decrease(int $weeks = 0, int $days = 0, int $hours = 0, int $minutes = 0, int $seconds = 0, int $microseconds = 0): Duration Duration::sum(Duration ...$duration): Duration Duration::multipliedBy(int $factor): Duration Duration::dividedBy(int $factor): Duration Duration::roundTo(Unit $precision, RoundingStrategy $strategy): Duration Duration::clamp(Duration $min, Duration $max): Duration
You can:
- make it unsigned using the
Duration::absmethod - invert its signing using the
Duration::negatemethod - update the duration using fixed duration parts with the
Duration::increaseandDuration::decreasemethods - round its value to one of the unit declare on the
Bakame\Tokei\Unitenum - clamp its value against two other
Durationinstances - sum multiple
Durationinstance using theDuration::summethod - multiply or divide a
Durationinstance using theDuration::multipliedByandDuration::dividevBymethods
$microseconds = 3_661_500_000; $a = Duration::of(microseconds: $microseconds); $b = $a->roundTo(Unit::Minute, RoundingStrategy::Ceil); $c = $b->negate(); $d = $c->decrement(minutes: 10); echo $a->format(DurationFormat::Timer); // returns "1:01:01.500000" echo $b->format(DurationFormat::Timer); // returns "1:01:00" echo $c->format(DurationFormat::Timer); // returns "-1:01:00" echo $c->abs()->format(DurationFormat::Timer); // returns "1:01:00" echo $a->sum($b, $c, $d)->format(DurationFormat::Timer); // returns "-0:09:58.500000" $microseconds = 3_761_500_000; $a = Duration::of(microseconds: $microseconds); $a->format(DurationFormat::Timer); // returns "1:02:41.500000" $a->roundTo(Unit::Minute, RoudingMode::Truncate)->format(DurationFormat::Timer); // returns "1:02:00" $a->roundTo(Unit::Minute, RoudingMode::Round)->format(DurationFormat::Timer); // returns "1:03:00"
Important
Duration::increase and Duration::decrease can only take non-negative arguments otherwise an exception will be
throw Use Duration::sum to aggregate signed duration objects.
Comparing duration
It is possible to compare duration using common methods terminology
Duration::compareTo(Duration $other): int;
Returns:
-1if shorter0if equal1if longer
Convenient methods based on Duration::compareTo are also available:
$duration = Duration::of(microseconds: 3_661_500_000); $other = Duration::fromFormat('PT1H1S'); $duration->isShorterThan($other); // returns false $duration->isShorterThanOrEqual($other); // returns false $duration->equals($other); // returns false $duration->isLongerThan($other); // returns true $duration->isLongerThanOrEqual($other); // returns true
Interval
Bakame\Tokei\Interval represents a start-inclusive, end-exclusive interval between two times on a 24-hour circular clock.
Intervals are immutable and support:
- circular ranges crossing midnight,
- interval algebra,
- time iteration,
- normalization,
- duration arithmetic.
The library uses half-open interval semantic where start is inclusive and end is exclusive.
If end < start, the interval is considered to wrap around midnight.
The library also support both collapsed and circular intervals for which start == end.
The distinction between them lies in their duration:
- a collapsed interval has a duration of PT0S, representing an empty interval;
- a circular interval has a duration of P1D, representing a full-day interval.
for instance:
Interval::between(Time::midnight(), Time::at(10)); //represents 08:00 ≤ time < 10:00 Interval::between(Time::at(hour: 22), Time::at(hour: 6)); // represents 22:00 → 06:00 (next day) Interval::collapsed(Time::midnight()); // represents 00:00:00/PT0S Interval::circular(Time::midnight()); // represents 00:00:00/P1D
An interval can, thus, be defined as either:
- a continuous span of time between two points in time, or
- a continuous span of time starting at a specific point in time with a given duration.
Instantiation
Interval::between(Time $start, Time $end): self; Interval::since(Time $start, Duration $duration): self; Interval::until(Time $end, Duration $duration): self; Interval::around(Time $midRange, Duration $duration): self; Interval::collapsed(Time $at): self; Interval::circular(Time $at): self; Interval::fullDay(): self //a 24h-long instance starting at 00:00:00 // equivalent to Interval::circular(Time::midnight()); Interval::fromFormat(string $value, IntervalFormat $format, ?Unit $unit = null): self
Accessors
$interval = Interval::between(Time::midnight(), Time::noon()); $interval->start; // returns Time::midnight() $interval->end, // returns Time::noon() $interval->duration; // returns Duration::of(hours: 12); $interval->type; // returns IntervalType
Interval Type
enum IntervalType { case Linear; // returns true (start < end) case Overflow; // returns false (start > end) case Circular; // returns false (start === end and duration is 'P1D') case Collapsed; // returns false (start === end and duration is 'PT0S') }
Formatting
using the following Enum:
enum IntervalFormat { case Iso8601StartDuration; case Iso8601DurationEnd; case Iso8601StartEnd; case Iso8601; case Iso80000; case Bourbaki; }
Out of the box, to following formatting algorithm are possible:
Iso8601StartDurationreturns a string representation based on the starting time and the interval duration;Iso8601DurationEndreturns a string representation based on the interval duration and the ending time;Iso8601StartEndreturns a string representation based on the interval starting and ending times;Iso8601returns the same representation asIso8601StartDuration;Iso80000returns a string representation based on the interval starting and ending times and the half-open bound;Bourbakireturns a string representation based on the interval starting and ending times and the half-open bound, with different boundary markers;
$interval = Interval::between(Time::midnight(), Time::noon()); $interval->format(IntervalFormat::Iso8601StartDuration); // returns 00:00:00/PT12H $interval->format(IntervalFormat::Iso8601StartEnd); // returns 00:00:00/12:00:00 $interval->format(IntervalFormat::Iso8601DurationEnd); // returns PT12H?00:00:00 $interval->format(IntervalFormat::Iso80000); // returns [00:00:00,12:00:00) $interval->format(IntervalFormat::Bourbaki); // returns [00:00:00,12:00:00[
Important
The same Enum is used when using Duration::fromFormat, the only difference is on instantiation,
The IntervalFormat::Iso8601 will be lenient and accept any ISO8601 supported format.
Iterations
enum Bound { case Start; case End; }
Interval::steps(Duration $duration, Bound $from = Bound:Start): iterable<Time> Interval::splitBy(Duration $duration, Bound $from = Bound:Start): IntervalSet Interval::splitAt(Time ...$steps): IntervalSet
Modifying by duration and/or time
Interval::startingOn(Time $time): self Interval::endingOn(Time $time): self Interval::expand(Duration $duration): self Interval::shift(Duration $duration): self Interval::shiftBound(Duration $duration, Bound $from): self Interval::lasting(Duration $duration, Bound $from): self Interval::complement(): self
Strict Comparison
The method compares the instance endpoint as well as its duration.
Interval::equals(Interval $other): bool
Duration based comparison
Interval::compareDurationTo(Interval $other): int Interval::sameDurationAs(Interval $other): bool Interval::longerThan(Interval $other): bool Interval::longerThanOrEqual(Interval $other): bool Interval::shorterThan(Interval $other): bool Interval::shorterThanOrEqual(Interval $other): bool
Time based comparison
Interval::includes(Time $time): bool Interval::contains(Interval $other): bool Interval::overlaps(Interval $other): bool Interval::abuts(Interval $other): bool Interval::intersect(Interval $other): ?self Interval::gap(Interval $other): ?self Interval::union(Interval $other): IntervalSet Interval::difference(Interval $other): IntervalSet
Interacting with PHP's native Date API
Interval::toNative(DateTimeInterface $reference): array // returns array{startDate: DateTimeImmuable, interval: DateInterval}
The reference DateTimeInterface object is used to compute the starting date
using Time::applyTo method.
IntervalSet
Bakame\Tokei\IntervalSet is an immutable collection of Bakame\Tokei\Interval instances
implementing PHP's Countable and IteratorAggregate interfaces. it represents
a collection of intervals treated as a single temporal domain.
it supports:
- interval normalization,
- union, intersection, and difference operations,
- containment checks,
- interval splitting and merging,
- iteration over intervals.
Overlapping or adjacent intervals may be merged during normalization to produce a minimal and consistent representation.
An IntervalSet may contain zero, one, or multiple non-overlapping intervals, including collapsed and circular intervals.
Instantiation
use Bakame\Tokei\IntervalSet; IntervalSet::__constrcut(Interval|IntervalSet ....$interval)
Accessors
IntervalSet::duration(): Duration IntervalSet::all(): list<Interval> IntervalSet::first(): ?Interval IntervalSet::last(): ?Interval IntervalSet::nth(int $nth): ?Interval IntervalSet::get(int $nth): Interval IntervalSet::indexOf(Interval $interval): ?int IntervalSet::lastIndexOf(Interval $interval): ?int IntervalSet::has(Interval ...$intervals): bool IntervalSet::isEmpty(): bool
nth and get supports negative index but differ on failure:
nthreturnsnullon invalid offset;getthrows aTimeExceptionexception on invalid offset;
Formatting
Supports the same formatting arguments as the Interval::format method.
IntervalSet::allFormatted(
IntervalFormat $format = IntervalFormat::Iso8601StartDuration,
?Unit $unit = null,
): list<string> //all interval are converted to their Interval::format string representation
Interacting with PHP's native Date API
IntervalSet::allNative(DateTimeInterface $reference): array
Returns the list of Interval instances converted using their Interval::toNative method.
Interval methods
IntervalSet::union(): IntervalSet IntervalSet::complement(): IntervalSet IntervalSet::intersect(IntervalSet|Interval ...$others): IntervalSet IntervalSet::difference(IntervalSet|Interval ...$others): IntervalSet IntervalSet::gaps(): IntervalSet IntervalSet::sorted(Bound $sortBound = Bound::Start, SortDirection|string $sortDirection = 'asc'): IntervalSet;
Collection methods
IntervalSet::any(callable $callback): bool IntervalSet::every(callable $callback): bool IntervalSet::each(callable $callback): bool IntervalSet::map(callable $callback): iterable IntervalSet::transform(callable $callback): IntervalSet IntervalSet::reduce(callable $callback, mixed $initial = null): mixed IntervalSet::filter(callable $callback): IntervalSet IntervalSet::sortedUsing(callable $callback): IntervalSet; IntervalSet::push(IntervalSet|Interval ...$items): IntervalSet IntervalSet::unshift(IntervalSet|Interval ...$items): IntervalSet IntervalSet::remove(int ...$offset): IntervalSet IntervalSet::replace(int $offset, Interval $newInterval): IntervalSet IntervalSet::firstMatching(callable $callback): ?Interval IntervalSet::lastMatching(callable $callback): ?Interval
Testing
The library has:
- a PHPUnit test suite.
- a coding style compliance test suite using PHP CS Fixer.
- a code analysis compliance test suite using PHPStan.
To run the tests, run the following command from the project folder.
composer test
Contributing
Contributions are welcome and will be fully credited. Please see CONTRIBUTING and CONDUCT for details.
Security
If you discover any security related issues, please email nyamsprod@gmail.com instead of using the issue tracker.
Changelog
Please see CHANGELOG for more information on what has changed recently.