cakephp/chronos
Chronos is an immutable date and datetime library for PHP. It offers Chronos and ChronosDate objects that prevent accidental modification, keeping data predictable, and adds handy conveniences beyond native DateTime. Install via Composer with cakephp/chronos.
Install and pick your version deliberately
composer require cakephp/chronos
Chronos 3.x is the current line and is a different beast from 2.x: Chronos no longer extends DateTimeImmutable, no longer implements DateTimeInterface, and the mutable classes and Carbon compatibility shims are gone. Check the version map on the wiki before pinning, especially if you are stuck on an older PHP. If you inherit a 2.x codebase, budget an upgrade pass (see Gotchas) before writing new code.
Where to look first
README.md — installation, immutability, ChronosDate, ChronosTime, setTestNow(), ClockFactory. It is short and covers 90% of daily use.book.cakephp.org/chronos/3/ — the narrative docs, including the 2.4 → 3.0 upgrade guide.api.cakephp.org/chronos — authoritative method list. Do not assume a Carbon method exists just because it existed in 2015; the shims were removed.Your first real use case
Anywhere you currently mutate DateTime in service or domain code — billing periods, trial expiry, reminders, "business hours" logic. Start by replacing the mutable flow with an immutable one:
use Cake\Chronos\Chronos;
$trialStart = Chronos::parse('2024-03-01 09:00:00', 'UTC');
// Wrong: result is discarded
$trialStart->modify('+14 days');
// Right: reassign
$trialEnd = $trialStart->addDays(14);
Choose the class before you write the method
| Need | Use |
|---|---|
| An instant in time (timestamps, expiries, ordering) | Chronos |
| A calendar day with no meaningful time | ChronosDate |
| A time-of-day with no date (opening hours, schedules) | ChronosTime |
use Cake\Chronos\ChronosDate;
use Cake\Chronos\ChronosTime;
$birthday = new ChronosDate('1990-04-12');
echo $birthday; // 1990-04-12
echo $birthday->modify('+3 hours'); // still 1990-04-12 (time is frozen)
$opening = ChronosTime::create(9, 0, 0);
$closing = $opening->addHours(8)->addMinutes(30);
echo $closing->format('g:i A'); // 5:30 PM
In a Laravel app, treat Chronos as a boundary library
Laravel's now(), Eloquent date casts, and validation rules speak Carbon. Chronos will not satisfy a DateTimeInterface type hint in 3.x. Use Chronos inside your domain/service layer and convert at the edges rather than trying to replace the framework's date stack.
Wrap Chronos in a domain type instead of passing it around
final class BillingPeriod
{
public function __construct(
public readonly Chronos $start,
public readonly Chronos $end,
) {
if ($end->lessThanOrEqualTo($start)) {
throw new InvalidArgumentException('End must follow start.');
}
}
public function days(): int
{
return (int) $this->start->diffInDays($this->end);
}
}
This keeps Chronos out of your public API, which matters because its type relationship with DateTimeInterface changed in 3.0 and may change again.
Persist UTC, present local
// Write
$row->starts_at = $event->start->setTimezone('UTC')->format('Y-m-d H:i:s');
// Read (explicit input timezone, never rely on the server default)
$start = Chronos::createFromFormat('Y-m-d H:i:s', $row->starts_at, 'UTC');
$local = $start->setTimezone($user->timezone);
Note the two timezone methods and use them for what they mean:
setTimezone($tz) — same instant, different representation.shiftTimezone($tz) — same wall-clock numbers, new zone. Use this when a stored "2024-03-01 09:00" is really a local schedule, not an instant.Recurrence and schedules with ChronosTime
$standup = ChronosTime::create(9, 30, 0);
$next = $standup->nextOccurrenceOf(Chronos::now('UTC')); // next 09:30
$prev = $standup->previousOccurrenceOf(Chronos::now('UTC'));
$window = [$standup->startOfHour(), $standup->endOfHour()];
ChronosTime also carries the usual comparison helpers (isBefore, isAfter, etc.), so sorting a list of business hours does not require inventing a dummy date.
Wall-clock vs elapsed arithmetic — pick the right one
$start = Chronos::parse('2024-03-31 01:30:00', 'Europe/Berlin');
$wall = $start->addHours(2); // may cross a DST jump; wall-clock semantics
$exact = $start->addElapsedHours(2); // Unix-timestamp arithmetic, DST-safe
Rule of thumb:
diffInHours() result (timeouts, TTLs, SLA clocks, "N hours from now") → addElapsedHours() / addElapsedMinutes() / addElapsedSeconds().addHours() / addDays() / addMonths().Calendar ranges using ChronosDate
$month = new ChronosDate('2024-03-01');
$first = $month->startOfMonth();
$last = $month->endOfMonth();
foreach ($first->to($last) as $day) { // DatePeriod-style iteration
// $day is a ChronosDate; time components never leak in
}
Because ChronosDate freezes time at 00:00:00, comparisons and array keys behave predictably — no "off by 23 hours" bugs from a stray now().
Inject a clock, don't call Chronos::now() in constructors
use Cake\Chronos\ClockFactory;
use Psr\Clock\ClockInterface;
$clock = new ClockFactory('UTC');
final class TrialService
{
public function __construct(private ClockInterface $clock) {}
public function start(): Chronos
{
return $this->clock->now();
}
}
This is the cleanest test seam and works with any PSR-20 container binding. Reserve the static setTestNow() for legacy code and quick tests.
Scoped time freezing in tests
Chronos::withTestNow('2024-01-15 10:00:00', function () {
$this->assertSame('2024-01-15', (string) new ChronosDate());
});
// clock restored automatically, even on exception
Prefer withTestNow() over setTestNow()/setTestNow(null) pairs: the callback form cannot leak into the next test if an assertion throws.
Integration tips for Laravel
$model->starts_at = $chronos->toNative(); (3.x exposes a native DateTimeImmutable conversion; if your pinned version lacks it, use new DateTimeImmutable($chronos->format('Y-m-d H:i:s.u'), $chronos->getTimezone())).DateTimeInterface in your adapter and construct Chronos from it, rather than type-hinting Chronos on controllers or models.createFromFormat call in scattered services is a future timezone bug.Carbon::setTestNow() and Chronos::setTestNow() and expect one to affect the other — they are independent static clocks. A feature test that freezes Carbon will still see real time in Chronos code.->toIso8601String() or ->format('c')) instead of relying on implicit JSON serialization, so your contract survives version bumps.Human-readable output and comparison
$invoice->due->diffForHumans(); // "in 3 days"
$invoice->due->isPast();
$invoice->due->between($start, $end); // inclusive by default
Recent 3.x versions return floats from diffIn*, so cast deliberately when you need integer day counts for storage or display.
Immutability silently eats modifications. The single most common bug is $date->modify('+1 day'); with no assignment. Static analysis will not catch this. Code review rule: any Chronos call whose return value is unused is a bug. Same for setTimezone, addDays, startOfDay, and the setters.
ChronosDate ignores time modifications by design. modify('+3 hours'), addHours(5), and setTime() produce the same calendar day. If you find yourself wanting the time back, you picked the wrong class — use Chronos.
ChronosDate uses the server default timezone. It is not "timezone-free"; the zone is whatever PHP defaults to. In long-running workers or queue consumers, set date_default_timezone_set() in bootstrap or explicitly pass zones when converting to instants. A calendar date converted to an instant with an implicit zone is a classic off-by-one-day bug around midnight.
No DateTimeInterface in 3.x. Type hints against DateTimeInterface, DateTimeImmutable, or Carbon will reject a Chronos. This bites in Laravel date casts, Symfony validators, and third-party SDKs. Convert with toNative() at the edge, or write your own narrow interface and adapt. Do not add Chronos to an Eloquent $casts array expecting it to work.
ChronosDate and Chronos are not interchangeable types in 3.x, deliberately. A function accepting Chronos should not accept a ChronosDate, and vice versa. If you need a shared abstraction, define one yourself rather than reaching for the package's interface.
Don't trust createFromFormat() plus frozen time. Missing components in the format are filled from "now", which means tests can drift with the wall clock. Release 3.3.2 fixed setTestNow being ignored for missing components, and 3.3.3 reverted part of that for the 'U' timestamp format to avoid a BC break. Practical guidance: always supply every component you care about, or pass an explicit base time; avoid depending on the filler behavior, and never mix 'U' with a frozen clock.
Validate parsing failures explicitly. Wrap createFromFormat() and parse() in try/catch — success/failure signaling has changed across PHP and Chronos versions, and user-supplied input will hit it.
diffIn* vs add* must be paired correctly. diffInHours() measures elapsed time; addHours() moves the wall clock. Across a DST transition they disagree by an hour. If you cache a TTL as addHours() and later verify with diffInHours(), your expiry is wrong twice a year. Use addElapsedHours() when the diff must be the inverse.
Zero-interval DatePeriod loops. Fixed in 3.3.2 — an interval of PT0S used to spin forever. If you are on an older 3.x, guard interval construction (new DateInterval('PT1H') minimum) before iterating.
Fractional seconds in ChronosTime. Parsing of fractional seconds was corrected in 3.5.1; older versions could treat .5 incorrectly. If you store sub-second precision in time-only columns, pin >= 3.5.1 or normalize on input.
2.x → 3.x is not a drop-in. MutableDate/MutableDateTime are removed; Date became ChronosDate; Chronos stopped extending DateTimeImmutable; Carbon compatibility methods were dropped. Run the upgrade guide, enable deprecation logging on 2.5 first, and grep for Mutable, Date::, createFromTimestampUTC, and any DateTimeInterface hints touching Chronos.
Deprecated timestamp constructors. createFromTimestampUTC() was deprecated in the 2.x line; use createFromTimestamp() (and note the PHP 8.4 signature change in 2.5.1 for float timestamps). Keep this consistent across your adapter.
Prefer withTestNow() and DI over static state. setTestNow() is global and survives across tests within the same process if you forget to reset it — a common cause of "passes alone, fails in suite." The callback form is exception-safe. ClockFactory is the better long-term answer for production code.
Comparison via == on objects is unreliable. Two Chronos instances representing the same instant may not be identical. Use equals(), lessThan(), greaterThanOrEqualTo(), or compare formatted strings. Equality also depends on timezone representation, so normalize to UTC before asserting in tests.
Extension points worth knowing. Chronos::setToStringFormat() changes what (string) $date yields application-wide — useful for consistent logging, dangerous if libraries rely on ISO output. Chronos::now($tz) accepts a timezone (string or DateTimeZone), so avoid relying on the global default in services. toArray() (added in 3.4.0) gives you structured components for debugging and API envelopes.
Debugging tip. When a date looks wrong, print all of these at once: format('c'), getTimezone()->getName(), and getTimestamp(). Almost every Chronos bug is one of three things — an abandoned return value, a setTimezone/shiftTimezone mix-up, or a static clock that wasn't reset.
How can I help you explore Laravel packages today?