healthengine/laravel-chronos
Chronos, from HealthEngine, gives Laravel apps control over the clock: freeze, travel to, or shift time in your application and test suite so date-dependent logic stays deterministic. Ideal for testing scheduled jobs, expiries, and time-sensitive features.
What the package actually does. healthengine/laravel-chronos rebinds Laravel's date/time plumbing from nesbot/carbon (via Illuminate\Support\Carbon) to cakephp/chronos. It is a swap, not a wrapper — after installation, date factories, mutable/immutable date classes, and the Date facade resolve to Chronos types. The realistic buyer is a team running a Chronos-heavy codebase (typically a CakePHP service, or a shared domain library) that wants one date API across both.
Before you install, do the diligence. This package is archived with 3 stars, 0 dependents, and a last release of 2024-06-28. Treat it as a reference implementation or a fork candidate, not a maintained dependency. Read three files first:
composer.json — which Chronos major it pins (^2 vs ^3 is a breaking difference; ChronosInterface disappears in 3.x) and which Laravel versions it claims.Minimal install.
composer require healthengine/laravel-chronos
# if a provider/config is not auto-discovered:
php artisan vendor:publish --provider="Healthengine\Chronos\ChronosServiceProvider"
php artisan config:clear && php artisan cache:clear
First use case / smoke test. Confirm the swap took hold before writing any feature code:
// php artisan tinker
get_class(now()); // expect Cake\Chronos\Chronos, not Illuminate\Support\Carbon
get_class(now()->addDay()); // mutable or immutable?
get_class(\Illuminate\Support\Facades\Date::now());
get_class((new \App\Models\Event)->created_at ?? new stdClass);
If now() still returns Illuminate\Support\Carbon, the provider didn't load — check bootstrap/providers.php (Laravel 11+) vs config/app.php (10 and below).
Pattern 1 — Bind the clock once, inject it everywhere.
The swap only changes the class behind now(). That is not enough discipline for a codebase that will also contain packages still calling Carbon. Introduce your own seam so the swap is reversible:
namespace App\Support;
use Cake\Chronos\ChronosImmutable;
final class Clock
{
public static function now(): ChronosImmutable
{
return ChronosImmutable::now(config('app.timezone'));
}
public static function parse(string $value): ChronosImmutable
{
return new ChronosImmutable($value, config('app.timezone'));
}
}
Application code calls Clock::now(). If the package dies (it is archived), you change one file.
Pattern 2 — Prefer ChronosImmutable in domain code.
Chronos is roughly API-compatible with Carbon, and its main selling point over Carbon is that immutability is the default idiom rather than an opt-in subclass. If you are accepting the swap cost, take the win: use ChronosImmutable in entities, value objects, and DTOs, and keep mutable Chronos confined to builders/accumulators.
Pattern 3 — Custom Eloquent casts, not $dates.
Laravel's built-in datetime cast and Model::asDateTime() are hardcoded to Illuminate\Support\Carbon. Do not assume the provider fixes them. Register explicit casts:
namespace App\Casts;
use Cake\Chronos\ChronosImmutable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class AsChronos implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?ChronosImmutable
{
return $value === null ? null : new ChronosImmutable($value, config('app.timezone'));
}
public function set($model, string $key, $value, array $attributes): ?string
{
return $value === null ? null : (new ChronosImmutable($value))->format('Y-m-d H:i:s');
}
}
Then protected $casts = ['starts_at' => AsChronos::class];. This survives package abandonment and is testable in isolation.
Pattern 4 — Freeze time in tests, and verify both clocks.
$this->travelTo() / $this->freezeTime() are implemented on top of Carbon::setTestNow(). A Chronos swap does not automatically participate. Make the freeze explicit:
protected function setUp(): void
{
parent::setUp();
Chronos::setTestNow(Chronos::parse('2024-06-28 12:00:00'));
Carbon::setTestNow('2024-06-28 12:00:00'); // stragglers
}
protected function tearDown(): void
{
Chronos::setTestNow();
Carbon::setTestNow();
parent::tearDown();
}
The second line is not paranoia — nesbot/carbon remains installed because Laravel requires it, so any dependency that calls Carbon::now() will see real time while your code sees frozen time. This is the single most common source of flaky tests after a swap.
Pattern 5 — Strangler migration, not big bang.
Do not convert the whole app. Start with new modules, import with an alias so diffs stay readable, and add an architecture rule (PHPStan, or a Pest arch test) forbidding new Carbon\Carbon imports:
use Cake\Chronos\ChronosImmutable as DateTime;
Then convert existing call sites module by module, gated on green tests.
The package is archived. Plan for ownership. Pin an exact version ("healthengine/laravel-chronos": "x.y.z", not ^x.y), vendor the service provider's bindings into your own AppServiceProvider, and assume you will fork. Budget a day for the fork; it is a small file.
Carbon never leaves the building. composer why nesbot/carbon will always report a dependency, because Laravel core requires it. You are running two date libraries simultaneously. Every third-party package that type-hints Carbon\CarbonInterface, Carbon\CarbonImmutable, or DateTimeInterface with Carbon semantics is a compatibility risk — audit spatie/*, audit/auditing packages, queue payloads, and any SDK that returns dates.
Interface mismatches surface as TypeError, not as wrong dates. Chronos implements its own contract, not Carbon\CarbonInterface. Any method signature you accept from a vendor package (function f(CarbonInterface $d)) will reject your Chronos instance. Convert at the boundary:
VendorSdk::schedule($chronosDate->toDateTimeImmutable()); // or ->toNative()
Timezone is not inherited the way you expect. Carbon::now() picks up Laravel's config('app.timezone') in many paths; Chronos defaults to PHP's date_default_timezone_get(). Always pass the timezone explicitly (ChronosImmutable::now(config('app.timezone'))) rather than relying on ambient state — and never rely on date_default_timezone_set() in a request lifecycle.
diffForHumans() output changes. Chronos's humanizer is a reimplementation. Locale coverage, pluralisation, and phrasing differ from Carbon's. Any UI showing "2 days ago" needs a screenshot review, not just a passing test.
Serialization and API contracts. Check __toString(), toIso8601String(), and json_encode() output against your existing API snapshots before merging. Chronos's default string format and Carbon's can diverge, and Model::serializeDate() may still route through Carbon. Run a full response-diff over your feature suite.
Validation is unaffected — and that's a trap. The date, date_format, and after_or_equal rules are string-based and keep working. They will happily accept input that your Chronos cast then parses with different leniency. Test the parse path, not just the rule.
Debugging recipe. When a date looks wrong, trace the class, not the value:
grep -rn "Carbon::" app/ --include=*.php | grep -v Chronos
php artisan tinker --execute="dump(get_class(now()), get_class(now()->addDay()), date_default_timezone_get());"
If now() is Chronos but $model->created_at is Carbon, the problem is a model cast, not the provider. If both are Chronos but the values differ, it's timezone. Those are the only two failure modes worth checking first.
Extension points worth using. The container bindings (aliases for Chronos, ChronosImmutable, and the Date facade), a custom cast class per attribute type, and your own Clock seam. Everything else in the package is incidental — do not build on undocumented internals, because nobody is going to patch them for you.
How can I help you explore Laravel packages today?