digital-craftsman/datetime-parts
Value objects for precise date/time parts in PHP: Moment (UTC-based) plus Time, Date, Month, Year, Day/Weekday and collections. Avoid misleading DateTime comparisons, handle timezone-safe modifications across DST, with Symfony normalizers and Doctrine types.
## Getting Started
### Minimal Setup
1. **Installation**:
```bash
composer require digital-craftsman/date-time-precision:0.14.*
Pin to minor version to avoid breaking changes.
Basic Usage:
Replace \DateTime with Moment for precise time handling:
use DigitalCraftsman\DateTimePrecision\Moment;
use DigitalCraftsman\DateTimePrecision\Time;
$now = Moment::now(); // UTC by default
$openingTime = Time::fromString('09:00', 'Europe/Berlin');
First Use Case: Validate business hours in a specific timezone:
if ($now->isBeforeInTimeZone($openingTime, new \DateTimeZone('Europe/Berlin'))) {
throw new FacilityClosedException();
}
Moment: For exact timestamps (replaces \DateTime).Time/Date/Month/Year: For timezone-agnostic components.Timezone-Agnostic Comparisons:
// Compare dates without timezone pollution
$bookingDate = Date::fromString('2024-01-15');
if ($now->isDateBeforeInTimeZone($bookingDate, $userTimezone)) {
// Handle future booking
}
Immutable Modifications:
// Modify a moment in a specific timezone
$nextWeek = $now->modifyInTimeZone('+7 days', $facilityTimezone);
Database Integration:
#[ORM\Column(type: Moment::class)]
private Moment $createdAt;
Clock Abstraction:
Use Clock interface for testing (automatically injects FrozenClock in tests):
public function __construct(private Clock $clock) {}
Normalization: Works seamlessly with Symfony serializers (e.g., API Platform):
# config/packages/api_platform.yaml
api_platform:
formats:
jsonld: ['application/ld+json']
json: ['application/json']
Collections:
Use Days or Weekdays for bulk operations:
$weekdays = Weekdays::fromString('Mon,Wed,Fri');
Validation:
$eventDate->mustBeAfterOrEqualTo($now, new InvalidDateException());
Period Calculations:
$holidays = Date::fromString('2024-12-25')->datesUntil(
Date::fromString('2025-01-01'),
PeriodLimit::EXCLUDING_END
);
Timezone-Safe Formatting:
$formatted = $moment->formatInTimeZone('Y-m-d H:i', $userTimezone);
UTC Default:
All Moment objects are always stored in UTC internally. Modifications in other timezones are converted back to UTC.
modifyInTimeZone() explicitly for timezone-aware operations.Breaking Changes:
isDateBefore → isDateBeforeInTimeZone).0.14.* and check UPGRADE.md.Doctrine Quirks:
DATETIME(6) for millisecond precision.requiresSQLCommentHint() for custom SQL types.Immutability: All modifications return new instances. Avoid chaining without assignment:
// ❌ Loses reference
$now->modify('+1 day')->modify('+1 hour');
// ✅ Correct
$tomorrow = $now->modify('+1 day');
$tomorrowAtNoon = $tomorrow->modify('+12 hours');
Timezone Confusion:
Use formatInTimeZone() to inspect values:
$moment->formatInTimeZone('Y-m-d H:i P', $timezone);
Comparison Issues:
Prefer isBeforeInTimeZone() over diff() for clarity:
// ❌ Ambiguous
$now->diff($eventDate)->days > 0;
// ✅ Explicit
$now->isBeforeInTimeZone($eventDate, $timezone);
Testing:
FrozenClock to mock time:
$clock = new FrozenClock(Moment::fromString('2024-01-01'));
$service = new MyService($clock);
FrozenClock for custom time travel:
class TestClock extends FrozenClock {
public function advance(string $interval) {
$this->setTime($this->getTime()->modify($interval));
}
}
Custom Value Objects:
Extend AbstractPrecisionDateTime for domain-specific types:
class BusinessDay extends AbstractPrecisionDateTime {
public static function fromDate(Date $date): self {
return new self($date->toDateTimeInTimeZone(new \DateTimeZone('UTC')));
}
}
Doctrine Types:
Register custom types in src/Doctrine/DBAL/Types/:
use DigitalCraftsman\DateTimePrecision\Doctrine\Types\MomentType;
class CustomMomentType extends MomentType {
public function convertToDatabaseValue($value, AbstractPlatform $platform) {
// Custom logic
}
}
Normalizers:
Extend SelfAwareNormalizer for custom serialization:
use DigitalCraftsman\SelfAwareNormalizers\Normalizer\SelfAwareNormalizer;
class MomentNormalizer extends SelfAwareNormalizer {
public function normalize($object, $format = null, array $context = []) {
return ['@id' => $object->getIso(), 'timezone' => $object->getTimezone()->getName()];
}
}
Avoid toDateTime() in Loops:
The internal DateTime conversion is lightweight, but cache results if reused:
private $cachedDateTime;
public function getDateTimeInTimeZone(\DateTimeZone $timezone): \DateTime {
return $this->cachedDateTime ??= $this->toDateTimeInTimeZone($timezone);
}
Batch Operations:
Use Days::fromRange() or Month::yearsUntil() for bulk operations instead of loops.
```markdown
### Configuration Quirks
1. **Symfony Auto-Wiring**:
Ensure `Clock` is tagged as a service if not using autowiring:
```yaml
services:
DigitalCraftsman\DateTimePrecision\Clock\SystemClock:
tags: ['clock']
Doctrine Auto-Registration:
If types aren’t auto-registered, manually add to config/packages/doctrine.yaml:
doctrine:
dbal:
types:
moment: DigitalCraftsman\DateTimePrecision\Doctrine\Types\MomentType
date: DigitalCraftsman\DateTimePrecision\Doctrine\Types\DateType
Timezone Handling:
\DateTimeZone explicitly.VARCHAR(50)) and hydrate with new \DateTimeZone().FrozenClock Scope:
FrozenClock is environment-specific (test vs. prod). Verify it’s active:
$this->assertInstanceOf(FrozenClock::class, $this->clock);
Timezone Mismatches: Test comparisons across timezones:
$utcNow = Moment::fromString('2024-01-01T00:00:00Z');
$berlinNow = $utcNow->modifyInTimeZone('+1 hour', new \DateTimeZone('Europe/Berlin'));
$this->assertTrue($utcNow->isBeforeInTimeZone($berlinNow, new \DateTimeZone('Europe/Berlin')));
Edge Cases: Test DST transitions:
How can I help you explore Laravel packages today?