digital-craftsman/date-time-precision
PHP/Symfony package wrapping DateTime in precise value objects: Moment, Time, Date, Month, Year, Day and Weekday, plus collections. Keeps UTC internally and converts to the relevant timezone for modifications. Includes Symfony normalizers and Doctrine types, and is thoroughly tested.
Installation:
composer require digital-craftsman/date-time-precision:0.15.*
Pin to a minor version — this package is still pre-1.0 and ships breaking changes in minor releases (v0.15.0 has three). Patch releases (0.15.0 → 0.15.1) are drop-in and contain only performance work plus targeted bug fixes.
Upgrading from 0.14 — read UPGRADE.md first and expect these to bite:
Moment is always UTC now. A \DateTimeImmutable with an offset is converted on construction (the instant is preserved, the offset is not).Date throws InvalidDate for non-existent days (e.g. 2023-04-31). Previously permissive parsing is gone.Date::datesUntil, Month::monthsUntil, Year::yearsUntil return Dates/Months/Years collections, not arrays.composer update digital-craftsman/date-time-precision -W
Then grep for datesUntil|monthsUntil|yearsUntil|toTimeZone|modifyInTimeZone|distanceInMinutesTo.
Upgrading within 0.15 (e.g. to 0.15.1) — no code changes required, but two behaviour fixes land in 0.15.1 that are worth a deliberate look:
0005-03-01 used to be normalized, compared and stringified as 2005-03-01. If you store any dates with years below 100 (historical data, genealogy, test fixtures, imported archives), audit those rows — 0.15.1 will now read/compare them correctly, which may surface mismatches against data that was written wrong.Date throws InvalidDate for years outside 1–32767 (checkdate's range) instead of silently producing values that couldn't be formatted or compared. Sentinel dates like 0000-00-00, 9999-12-31-adjacent placeholders or -0001-style values will now throw at the boundary.composer update digital-craftsman/date-time-precision -W
Re-run any importer/seed fixture that writes far-future or zero-year dates.
Basic Usage:
use DigitalCraftsman\DateTimePrecision\Moment;
use DigitalCraftsman\DateTimePrecision\Time;
$now = Moment::now(); // always UTC
$opensAt = Time::fromString('09:00:00'); // a time of day, no zone
$date = Date::fromString('2024-03-01'); // throws InvalidDate on impossible dates
First use case — business-hours check that is explicit about the zone:
use DigitalCraftsman\DateTimePrecision\Moment;
if ($now->isBeforeInTimeZone($facility->opensAt, $facility->timezone)) {
throw new FacilityNotOpenException();
}
Moment — immutable instant, always UTC internally.Date, Time, Week (ISO 8601), Month, Year, Weekday, Day.Duration (elapsed time, microsecond precision, normalized as integer microseconds) and CalendarPeriod + CalendarUnit (days, weeks, months, quarters, years).DateRange (closed), MomentRange (half-open), TimeRange (half-open, may wrap midnight).Recurrence — daily / weekly-on-weekdays / monthly-on-days scheduling.Moments, Dates, Times, Weeks, Months, Years, Durations, CalendarPeriods, DateRanges, MomentRanges, TimeRanges, Recurrences, plus the existing Days, Weekdays.Clock — SystemClock by default, FrozenClock for tests.Day and Days.// Moment is UTC; the *InTimeZone variants anchor the calendar value to a zone
if ($appointment->scheduledAt->isBeforeInTimeZone($facility->closingTime, $facility->timezone)) {
// still within opening hours
}
// Combine a local date + local time into a UTC instant
$startsAt = $booking->date->atTimeInTimeZone($booking->startsAtTime, $customer->timezone);
// Calendar week of a moment, as seen in the customer's zone
$week = $appointment->scheduledAt->weekInTimeZone($customer->timezone);
The distinction is the single most important new concept in 0.15. Pick deliberately:
use DigitalCraftsman\DateTimePrecision\CalendarPeriod;
use DigitalCraftsman\DateTimePrecision\CalendarUnit;
// "24 hours later" — an elapsed duration
$expiresAt = $startedAt->add($startedAt->durationUntil($now));
// "Tomorrow at the same wall-clock time" — a calendar movement
$reminderAt = $startedAt->addInTimeZone(
CalendarPeriod::from(CalendarUnit::day(), 1),
$customer->timezone,
);
// Date / Month / Year move without a zone
$nextMonth = Month::fromString('2024-01')->next();
// Weekday arithmetic
$nextMonday = Weekday::monday()->next();
Rule of thumb: Duration for SLAs, timeouts and TTLs; CalendarPeriod for "same time next week/month" business rules. Duration around a DST change adds the real elapsed time; CalendarPeriod preserves the local clock time.
if chainsuse DigitalCraftsman\DateTimePrecision\DateRange;
use DigitalCraftsman\DateTimePrecision\MomentRange;
use DigitalCraftsman\DateTimePrecision\PeriodLimit;
// Half-open: [start, end) — the right default for booking windows
$window = new MomentRange($availableFrom, $availableUntil);
if ($window->notContains($appointment->scheduledAt)) {
throw new SlotUnavailableException();
}
// Closed date range: both ends included — right for "holiday season"
$closure = new DateRange($firstClosedDay, $lastClosedDay);
// Override inclusivity when a legacy report counts both ends
$containsEnd = $window->contains($moment, PeriodLimit::includingStartAndEnd());
TimeRange is half-open and may wrap around midnight — use it for opening hours like 22:00–02:00, and the mustNotStartBefore / mustNotWrapAroundMidnight guards to reject input you don't support.
As of 0.15.1, DateRange boundary checks are integer comparisons internally — cheap enough to use inline in tight loops and sort callbacks rather than caching booleans.
use DigitalCraftsman\DateTimePrecision\Recurrence;
$standup = Recurrence::weeklyOnWeekdays([Weekday::monday(), Weekday::wednesday()]);
if ($standup->occursOn($date)) {
// create the meeting
}
$next = $standup->nextOccurrenceAfter($now);
$nextAt = $standup->nextOccurrenceAtTimeInTimeZone($now, $meetingTime, $team->timezone);
$past = $standup->previousOccurrenceOnOrBefore($now);
$all = $standup->occurrencesBetween($from, $to);
Guard untrusted input with mustOccurOn() before persisting a recurrence (it throws instead of silently storing nonsense).
use DigitalCraftsman\DateTimePrecision\Dates;
$holidays = Dates::fromListRemovingDuplicates($rows->pluck('date'));
if ($holidays->contains($date)) { /* ... */ }
$openHolidays = $holidays->filter(fn (Date $d) => ! $d->isWeekend());
foreach ($holidays as $holiday) { /* Countable + IteratorAggregate */ }
$ordered = $holidays->sort(); // ascending for ordered values
$first = $holidays->first();
$latest = $holidays->max(); // single pass as of 0.15.1
// Order-independent equality — ideal in assertions
$this->assertTrue($expectedDates->isEqualTo($actualDates));
Collections now throw CollectionContainsDuplicates (an \InvalidArgumentException) when built with duplicates — good, but catch it in request handling and turn it into a 422.
Since 0.15.1, construction, contains and fromListRemovingDuplicates use cheaper internal hash keys and min/max scan once instead of sorting — so building a Dates from a query result inside a request is far less costly than it was. Still avoid constructing them from unbounded result sets.
Doctrine (auto-registered types, use the FQCN):
#[ORM\Entity]
class Booking {
#[ORM\Column(type: Moment::class)]
public Moment $createdAt;
#[ORM\Column(type: Time::class)]
public Time $scheduledAtTime;
#[ORM\Column(type: Date::class)]
public Date $serviceDate;
}
Eloquent / query builder (no native casts ship with the package):
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class MomentCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): ?Moment
{
return $value === null ? null : Moment::fromString($value);
}
public function set($model, $key, $value, $attributes): ?string
{
return $value?->format('Y-m-d H:i:s.u');
}
}
Store Duration as a BIGINT of microseconds — it's explicitly normalized to that.
FrozenClock$this->app->instance(
Clock::class,
new FrozenClock(Moment::fromString('2024-03-01 12:00:00')),
);
Freeze at a DST boundary and at a month end (e.g. 2024-03-31, 2024-01-31) — those are where CalendarPeriod vs. Duration bugs hide.
Day/Days now that the missing registration is fixed).InvalidDate — a malformed date is now an exception, not a quietly rolled-over date, and as of 0.15.1 that includes out-of-range years:
class AvailableDateRule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$date = Date::fromString($value);
} catch (\Throwable) {
$fail('The :attribute is not a valid date.');
return;
}
if ($date->isBefore(Date::today())) {
$fail('The :attribute must be in the future.');
}
}
}
Moment::format() yields UTC — convert to the user's zone for display, or emit ISO-8601 and let the client localize.
return response()->json([
'opens_at' => $facility->opensAt->format('H:i'),
'starts_at' => $appointment->scheduledAt->format('c'),
]);
now(): don't mix in Carbon::now() or now() alongside Moment::now() in the same code path — bind everything to the Clock interface so FrozenClock actually freezes everything.Date::datesUntil() / Month::monthsUntil() / Year::yearsUntil() return collections now — you can ->filter(), count(), and foreach directly, but array_map()/array_chunk() will fail.Date::min(), Date::max(), Moment::compare() instead of hand-rolled comparisons in sort callbacks and assertions.Month::contains(), notContains() and dateRange() for month-scoped reporting; Month::numberOfDays() for calendars and billing pro-rata — it uses checkdate as of 0.15.1, so it's cheap enough to call per row.Date::week(), Date::startOfWeek(), Date::endOfWeek() for weekly groupings; toMomentRangeInTimeZone() to get a query-ready UTC half-open range.Moment is now unconditionally UTC.
Moment::fromString('2024-03-01T10:00:00+02:00') is 08:00 UTC.toTimeZone() is deprecated and has no effect — remove it. Formatting always emits UTC, so any UI that relied on format() returning local time will shift.$facility->timezone) and use the *InTimeZone methods.InvalidDate is a new failure mode — and 0.15.1 widens it to years.
Date::fromString('2023-02-30') throws instead of rolling to March 2.'0000-01-01', '9999-12-31' is fine but '99999-01-01', negative years and zero-year sentinels are not. Anything that previously "worked" with garbage input (importers, admin forms, firstOrCreate payloads, 0000-00-00 placeholder rows) now raises. Wrap at the boundary, log the offending value, don't @-suppress.try {
$date = Date::fromString($payload['date']);
} catch (InvalidDate $e) {
report($e);
throw ValidationException::withMessages(['date' => 'Not a real date.']);
}
Month/Year beyond whatever Date accepts — validate years yourself if you accept free-form numeric years.Until-collections break array code.
// ❌ TypeError — no longer an array
$months = Month::fromString('2024-01')->monthsUntil($end);
$chunks = array_chunk($months, 3);
// ✅
$chunks = $months->sort()->map(fn (Month $m) => $m->format('Y-m'))->toArray(); // or iterate
Duration vs. CalendarPeriod is not cosmetic.
add(Duration) over a DST transition gives a different wall-clock result than addInTimeZone(CalendarPeriod, tz).Duration drifts. Use CalendarPeriod with CalendarUnit::month().Half-open vs. closed ranges.
MomentRange and TimeRange exclude the end; DateRange includes both.contains($value, $limit) defaults differ from what a reviewer may assume — pass an explicit PeriodLimit anywhere a boundary is contested (billing periods, closing-day reports).TimeRange wrapping midnight means start > end is legal. Don't compare the two times directly; use the range.Immutability is still silent.
// ❌ no-op
$moment->addInTimeZone($period, $tz);
// ✅
$nextMonth = $moment->addInTimeZone($period, $tz);
Every method returns a new instance. Static analysis (@return $this-style narrowing, PHPStan at a decent level) will catch most of these; a codebase without PHPStan will not.
Microseconds in Time were broken below 100000.
Values created before 0.15 may be misinterpreted (5 microseconds was treated as 0.5 seconds) in comparisons, formatting and modifications. If you persist Time with microseconds, audit existing rows after upgrading — comparisons that "randomly" failed in 0.14 should start passing, and data that was written wrong stays wrong.
Years below 100 were normalized wrong before 0.15.1.
Date::fromString('0005-03-01')->format() (and normalize/__toString) emitted 2005-03-01; comparisons were equally off. Any rows written by a pre-0.15.1 version carry the wrong year on disk.// On 0.15.0 this was '2005-03-01'; on 0.15.1 it is '0005-03-01'
Date::fromString('0005-03-01')->format('Y-m-d');
Doctrine quirks.
#[ORM\Column(type: ...)].Day and Days types were silently unregistered before 0.15 — schemas/mappings using them may now behave differently (or start working). Re-run doctrine:schema:validate.requiresSQLCommentHint()) matter for Doctrine 3.x schema diffs; watch for spurious migration churn after upgrading.Deprecations to clear now.
| Deprecated | Replacement |
|---|---|
Moment::toTimeZone() |
nothing — delete the call |
Date/Month/Year::modifyInTimeZone() |
add / subtract with CalendarPeriod |
Time::distanceInMinutesTo() |
Time::durationUntil() |
$this->app->instance(Clock::class, new FrozenClock(Moment::fromString('2024-03-31 01:30:00')));
isEqualTo is order-independent and produces clearer diffs than a raw array compare.$timezone argument rather than a wrong instant:
Log::debug('Slot check', [
'moment' => $moment->format('c'), // UTC
'time' => $time->format('H:i:s'),
'timezone' => $facility->timezone,
]);
2024-03-31 02:30 Europe/Berlin and 2024-10-27 02:30 Europe/Berlin don't both exist / are ambiguous — assert the behaviour you want rather than discovering it in production.Date comparison or serialized date suddenly disagrees after a patch upgrade, check whether the year is < 100. That's the 0.15.1 fix, not a regression.0000-00-00, 10000-01-01 and negative years now raise InvalidDate from the constructor, fromString, denormalize and fromDateTime alike.digital-craftsman/self-aware-normalizers or format explicitly at the response boundary.TimeRange/DateRange over subclassing:
final class OpeningHours
{
public function __construct(private TimeRange $range, private string $timezone) {}
public function isOpenAt(Moment $moment): bool
{
return $this->range->contains($moment->time()->inTimeZone($this->timezone));
}
}
MomentCast, DateCast, DurationCast) — implement once, reuse everywhere; keep the raw column format stable so migrations don't depend on the package.Clock implementations for non-system time sources (NTP, an internal time API, a per-tenant simulated clock in staging):
final class TenantClock implements Clock
{
public function now(): Moment { return $this->tenant->frozen_at ?? Moment::now(); }
}
PeriodLimit presets in a small enum/helper (BillingPeriod::limit()) so half-open vs. closed is decided once per domain concept rather than per call site.Moment as a native timestamp column, not a string — the comparison operators are used in query building and string columns defeat index usage.\DateTimeZone instances in long-running consumers (queues, Octane) rather than constructing per iteration.Date construction in the normalized Y-m-d form, Date comparisons (isEqualTo, isBefore, compareTo, and transitively isBetween, min, max, DateRange), Month::numberOfDays(), Moment construction, and normalize/__toString for Date/Month/Week/Time no longer build intermediate \DateTimeImmutable instances. Hot paths that were previously "only in a job" — per-row date rendering, large DateRange filters — are now viable in request paths.min/max on Dates, Moments, Months, Weeks, Times, Years are single-pass as of 0.15.1, not sort-then-take. If you worked around them with a manual loop because sorting was too slow, revert to ->max().Date construction from non-normalized formats still goes through \DateTimeImmutable. If a parser is hot, normalize the input string to Y-m-d first and pass it to fromString.*Until/occurrencesBetween with unbounded or huge ranges in request paths — the collection members are cheaper now, but materializing decades of entries is still O(n). Bound the input.Date::compare() / static min/max in usort callbacks; they're cheaper than repeated formatting and, since 0.15.1, allocation-free.date.timezone is ignored for Moment — everything is UTC. Set the app timezone for display concerns only.timezone_identifiers_list() on input.config/packages/doctrine.yaml:
doctrine:
orm:
mappings:
datetime_precision:
type: attribute
prefix: 'DigitalCraftsman\DateTimePrecision'
dir: '%kernel.project_dir%/vendor/digital-craftsman/date-time-precision/src/Doctrine'
In Laravel + Doctrine, make sure the same types are added to the DBAL registry in a service provider; in pure Eloquent, none of this applies — use casts.checkdate's, hard-coded; it is not a publishable config value.How can I help you explore Laravel packages today?