brick/date-time
A powerful set of immutable PHP date-time classes built on native PHP and following ISO 8601, inspired by Java's JSR 310. Adds missing concepts such as LocalDate, LocalTime, YearMonth, and MonthDay. Requires PHP 8.2+.
Install and verify your runtime first
composer require brick/date-time
The library requires PHP 8.2+, which lines up with Laravel 11/12 — if you're still on Laravel 10 with PHP 8.1, you cannot install this at all. Check composer why-not brick/date-time before planning anything.
Where to look first
LocalDate, LocalTime, LocalDateTime, ZonedDateTime, Instant, Duration, Period, Interval, LocalDateRange, YearMonth, MonthDay, YearWeek, DayOfWeek, Month).Brick\DateTime\DefaultClock and the Clock\* implementations — this is the piece that changes how you write tests.IsoParsers / the static parse() methods plus Brick\DateTime\Parser\DateTimeParseException — this is your input-validation boundary.*NativeDateTime*() conversion methods — this is how the library talks to Eloquent, Carbon, and your database.Mental model before your first line of code
This is not a Carbon replacement and it will not slot into $table->timestamps() or Model::whereDate(). It is a set of immutable domain value objects to sit alongside Eloquent. The practical rule:
now() helper) use native DateTimeImmutable / Carbon and convert explicitly.First use case
The highest-value first win is usually a date range with real semantics — a booking window, a promo period, a billing cycle — where Carbon leaves you hand-rolling overlap checks and inclusive/exclusive off-by-ones:
use Brick\DateTime\LocalDate;
use Brick\DateTime\LocalDateRange;
$booking = LocalDateRange::of(
LocalDate::parse('2026-03-01'),
LocalDate::parse('2026-03-07'),
);
$promo = LocalDateRange::of(
LocalDate::parse('2026-03-05'),
LocalDate::parse('2026-03-20'),
);
$booking->intersectsWith($promo); // true
$booking->getIntersectionWith($promo); // 2026-03-05/2026-03-07
$booking->toDatePeriod(); // native DatePeriod for iteration
$booking->getStart()->daysUntil($booking->getEnd()); // int
That snippet alone — overlap, intersection, inclusive range, native interop — is the pitch. Once a team sees it, adoption spreads to business-day math (plusWeekdays(), minusWeekDays(), DayOfWeek::isWeekend()) and to Period/Duration correctness.
Pattern 1 — Explicit conversion at the persistence boundary (custom Eloquent cast)
Keep DB columns as native DATE / DATETIME / TIMESTAMP. Convert only in the cast. This is the single most important pattern; without it you will leak brick objects into query builders that don't understand them.
namespace App\Casts;
use Brick\DateTime\LocalDate;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class LocalDateCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): ?LocalDate
{
return $value === null ? null : LocalDate::parse(substr($value, 0, 10));
}
public function set($model, $key, $value, $attributes): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
$value = LocalDate::parse($value);
}
return (string) $value; // ISO-8601 'Y-m-d'
}
}
Then protected $casts = ['starts_on' => LocalDateCast::class];. Queries stay Carbon/native: ->whereDate('starts_on', '>=', $from->toNativeDateTimeImmutable()).
Pattern 2 — Adapter/service layer, not fat models
Put domain logic in a small service that accepts and returns brick types, and let controllers/actions do the conversion. This keeps DateTimeException handling in one place and keeps your models free of two competing date APIs.
final class AvailabilityService
{
public function isAvailable(LocalDateRange $requested): bool
{
return ! $this->bookings()
->map(fn ($b) => LocalDateRange::of($b->starts_on, $b->ends_on))
->contains(fn (LocalDateRange $r) => $r->intersectsWith($requested));
}
}
Pattern 3 — Time-zone discipline: Instant in storage, ZonedDateTime at the edges
Store UTC (timestamp/Instant), convert to a user's zone only when rendering. Prefer TimeZone::utc() and TimeZoneRegion::parse('Europe/London') over guessing. Because LocalDate has no zone, be explicit that "today" is a zone-dependent question:
use Brick\DateTime\LocalDate;
use Brick\DateTime\TimeZoneRegion;
$today = LocalDate::now(TimeZoneRegion::parse($user->timezone));
Pattern 4 — Testing with clocks instead of Carbon::setTestNow()
Two options, and the choice matters:
now() calls under test — LocalDate::now(TimeZone::utc(), $clock). Explicit, no global state, safe under parallel test runs and Octane.DefaultClock::freeze(...), travelTo(), travelBy(), scale(). Great for feature tests, but it is process-global static state — reset it in tearDown() without exception.protected function tearDown(): void
{
DefaultClock::reset();
parent::tearDown();
}
Pattern 5 — Input validation via parse() + exception
Laravel has no built-in rule for these types; write one and centralize the try/catch on DateTimeParseException.
final class IsoDate implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
LocalDate::parse((string) $value);
} catch (\Brick\DateTime\Parser\DateTimeParseException) {
$fail("The {$attribute} must be a valid ISO-8601 date.");
}
}
}
Pattern 6 — Native interop for everything else
The changelog shows a consistent fromNative* / toNative* family (fromNativeDateTime, toNativeDateTimeImmutable, Period::fromNativeDateInterval(), TimeZone::fromNativeDateTimeZone()). Use these rather than string round-trips — they preserve offsets and microseconds/nanos correctly and are cheaper. For anything Eloquent-bound (whereBetween, Carbon::diffForHumans, Blade formatting), convert out and stay native.
The package is 0.x — pin the release cycle. The README explicitly states a new 0.x cycle starts on every breaking change. Lock to "brick/date-time": "0.9.*" (or whatever current) and read the release history before bumping. Method names have churned repeatedly (fromDateTime → fromNativeDateTime, Duration::ofMilliseconds → ofMillis, getStartDate → getStart, TimeZoneOffset losing seconds support). Copy-pasting old StackOverflow snippets is a real failure mode here.
Immutability means you must reassign. Every plus*/minus*/with* returns a new instance. $date->plusDays(1); is a silent no-op. This is the #1 day-to-day bug. Consider static analysis (PHPStan/Larastan) to catch unused-expression results.
DefaultClock is process-global static state. Two hazards:
DefaultClock::reset() in tearDown().freeze() or scale() left set in one request persists across requests in a long-lived worker. Never set DefaultClock in application code — only in tests.Most classes are final (since 0.2.0). You cannot extend them, and you cannot Mockery-mock them. This is fine — they're immutable value objects, so construct real ones in tests. But if you were planning a "Carbon-like" wrapper subclass, the design won't allow it; wrap by composition instead.
LocalDate has no time zone, ZonedDateTime has no "local" ambiguity resolution you might expect. LocalDate::now() needs a TimeZone, and DST arithmetic differs by type: adding a Period of 1 day to a ZonedDateTime across a DST boundary is not the same as adding a Duration of 24 hours. Choose Period for calendar math, Duration for elapsed time — and be deliberate.
Precision mismatch at the database. Instant/Duration carry nanoseconds; MySQL DATETIME(6) and PHP's DateTimeImmutable only give microseconds. Truncation is silent. Don't build equality assertions on nanosecond-precision values that have round-tripped through storage.
Illegal dates throw, they don't roll over. DateTimeException on LocalDate::of(2026, 2, 30). That's a feature (fail fast), but it means user-supplied components need validation before construction — or wrap with a parse-and-catch. Conversely, MonthDay handles the recurring --02-29 case that Carbon makes awkward.
TimeZoneOffset rejects seconds (since 0.4.0). Offsets must be whole minutes; ofTotalSeconds() throws otherwise. This also affects ZonedDateTime::parse(). If you ingest offsets like +05:30:30 from a legacy feed, it will now fail.
Doctrine mapping is not Eloquent mapping. brick/date-time-doctrine is irrelevant to a standard Laravel app — it only helps if you run Doctrine via something like laravel-doctrine. For Eloquent, write the casts yourself (Pattern 1). This is the biggest "missing feature" of the package in a Laravel context.
Comparison is via methods, not operators. $a < $b doesn't work. Use isBefore(), isAfter(), compareTo(), and range methods like intersectsWith(). For sorting collections, map to (string) or a native timestamp — ISO-8601 strings sort lexicographically for same-offset values, but don't rely on that across offsets.
ISO week-based years are a trap. YearWeek and DayOfWeek follow ISO-8601, so 2026-01-01 may belong to the previous week-year. If you do any YearWeek reporting, verify boundaries explicitly rather than assuming Year + Week composition.
Check JSON/string behaviour before exposing objects in an API. Verify what json_encode() produces for each type in your version and always render (string) or an explicit ->toISOString()-style call in API Resources. Don't let the serialization format of a 0.x dependency leak into a public contract.
Escape hatch when you need to leave. LocalDateRange::toDatePeriod() and Period::toNativeDateInterval() give you native objects for anything brick doesn't cover — PDF libraries, DatePeriod iteration, third-party SDKs. Keep conversions one-directional and near the edge, and the library stays an asset rather than a lockdown.
How can I help you explore Laravel packages today?