Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Date Time Precision Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. 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.

  2. 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.

  3. 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:

    • Early years are fixed. 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.
    • Year range is now enforced. 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.

  4. 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
    
  5. 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();
    }
    

Key Entry Points

  • Moment — immutable instant, always UTC internally.
  • Calendar values — Date, Time, Week (ISO 8601), Month, Year, Weekday, Day.
  • Movements — Duration (elapsed time, microsecond precision, normalized as integer microseconds) and CalendarPeriod + CalendarUnit (days, weeks, months, quarters, years).
  • Ranges — DateRange (closed), MomentRange (half-open), TimeRange (half-open, may wrap midnight).
  • Recurrence — daily / weekly-on-weekdays / monthly-on-days scheduling.
  • Collections — Moments, Dates, Times, Weeks, Months, Years, Durations, CalendarPeriods, DateRanges, MomentRanges, TimeRanges, Recurrences, plus the existing Days, Weekdays.
  • Clock — SystemClock by default, FrozenClock for tests.
  • Doctrine types — auto-registered, now including Day and Days.

Implementation Patterns

Core Workflows

1. Timezone-aware comparisons

// 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);

2. Durations (elapsed time) vs. calendar periods (movement)

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.

3. Ranges instead of ad-hoc if chains

use 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.

4. Recurrence for repeating schedules

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).

5. Collections

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.

6. Persistence

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.

7. Testing with 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.

Integration Tips

Laravel-specific

  • No service provider needed for the value objects themselves. Doctrine types auto-register when the DBAL type registry boots; if you register Doctrine manually in a provider, ensure the package's types are added (and re-check Day/Days now that the missing registration is fixed).
  • Validation rules must catch 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.');
            }
        }
    }
    
  • API responses: format at the boundary, don't leak internals. 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'),
    ]);
    
  • Livewire / queues / 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.

Common patterns

  • Date::datesUntil() / Month::monthsUntil() / Year::yearsUntil() return collections now — you can ->filter(), count(), and foreach directly, but array_map()/array_chunk() will fail.
  • Use Date::min(), Date::max(), Moment::compare() instead of hand-rolled comparisons in sort callbacks and assertions.
  • Use 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.

Gotchas and Tips

Pitfalls

  1. Moment is now unconditionally UTC.

    • Constructing from an offset preserves the instant and drops the offset: 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.
    • Fix: carry the timezone as a separate, explicit field ($facility->timezone) and use the *InTimeZone methods.
  2. 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.
    • As of 0.15.1, years outside 1–32767 also throw. '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.']);
    }
    
    • Note there is no upper/lower range validation on Month/Year beyond whatever Date accepts — validate years yourself if you accept free-form numeric years.
  3. 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
    
  4. Duration vs. CalendarPeriod is not cosmetic.

    • add(Duration) over a DST transition gives a different wall-clock result than addInTimeZone(CalendarPeriod, tz).
    • "Renew monthly" implemented with a 30-day Duration drifts. Use CalendarPeriod with CalendarUnit::month().
  5. 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.
  6. 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.

  7. 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.

  8. 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.
    • After upgrading, the same value reads back as year 5 — audit stored dates where the true year is < 100 and reconcile. Don't "fix" this by bumping the year at write time; fix the data.
    // 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');
    
  9. Doctrine quirks.

    • Use the fully qualified class name in #[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.
    • Some DBAL methods (e.g. requiresSQLCommentHint()) matter for Doctrine 3.x schema diffs; watch for spurious migration churn after upgrading.
  10. Deprecations to clear now.

    Deprecated Replacement
    Moment::toTimeZone() nothing — delete the call
    Date/Month/Year::modifyInTimeZone() add / subtract with CalendarPeriod
    Time::distanceInMinutesTo() Time::durationUntil()

Debugging Tips

  • Freeze the clock centrally, not per test class:
    $this->app->instance(Clock::class, new FrozenClock(Moment::fromString('2024-03-31 01:30:00')));
    
  • Assert collection equality, not arrays — isEqualTo is order-independent and produces clearer diffs than a raw array compare.
  • Log the timezone string next to the moment in any comparison that fails; almost every "off by an hour" is a missing $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,
    ]);
    
  • DST boundaries are the test to write. 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.
  • "It changed year" — if a 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.
  • "It throws on a date that used to work" — check the year is within 1–32767. 0000-00-00, 10000-01-01 and negative years now raise InvalidDate from the constructor, fromString, denormalize and fromDateTime alike.
  • Serialization: the package doesn't ship normalizers; if you need JSON round-tripping, either add digital-craftsman/self-aware-normalizers or format explicitly at the response boundary.

Extension Points

  1. Domain wrappers over ranges — prefer composing 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));
        }
    }
    
  2. Custom Eloquent casts per value object (MomentCast, DateCast, DurationCast) — implement once, reuse everywhere; keep the raw column format stable so migrations don't depend on the package.
  3. 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(); }
    }
    
  4. 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.

Performance Notes

  • Store Moment as a native timestamp column, not a string — the comparison operators are used in query building and string columns defeat index usage.
  • Cache \DateTimeZone instances in long-running consumers (queues, Octane) rather than constructing per iteration.
  • 0.15.1 removes most of the object-allocation cost. 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().
  • Note 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.
  • Avoid *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.
  • Prefer Date::compare() / static min/max in usort callbacks; they're cheaper than repeated formatting and, since 0.15.1, allocation-free.

Configuration Quirks

  • PHP's date.timezone is ignored for Moment — everything is UTC. Set the app timezone for display concerns only.
  • Timezone identifiers are trusted strings. Typos surface as offsets/exceptions rather than validation errors; validate against timezone_identifiers_list() on input.
  • Doctrine mapping registration is the only config surface. For a Symfony bridge that means 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.
  • No date-year configuration exists. The accepted range (1–32767) is checkdate's, hard-coded; it is not a publishable config value.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource