beste/clock
A collection of PSR-20 clock implementations for PHP, including SystemClock, LocalizedClock, UTCClock, FrozenClock for tests, MinuteClock, and WrappingClock. Freeze time, set time zones, or wrap any object with a now() method. Install via composer require beste/clock.
beste/clock is a small set of PSR-20 (Psr\Clock\ClockInterface) implementations. There is no service provider, no facade, and no config file — you wire it yourself, which is exactly what makes it easy to swap in tests.
composer require beste/clock # PHP 8.1+, requires psr/clock ^1.0
First place to look is the README's clock table; you will realistically only use four of them day-to-day: UTCClock (persistence), SystemClock (display), LocalizedClock (business hours per region), and FrozenClock (tests).
Register a default binding in AppServiceProvider::register():
use Psr\Clock\ClockInterface;
use Beste\Clock\UTCClock;
public function register(): void
{
$this->app->singleton(ClockInterface::class, fn () => UTCClock::create());
}
Then inject the interface instead of calling now() / Carbon::now() directly:
final class StartTrial
{
public function __construct(private ClockInterface $clock) {}
public function __invoke(User $user): void
{
$startedAt = $this->clock->now(); // DateTimeImmutable, UTC
$user->trial_ends_at = $startedAt->modify('+14 days');
$user->save();
}
}
First concrete win: pick one time-dependent service (trial expiry, rate limiting, "is the promotion live?"), inject the clock, and write a test that freezes time. That single conversion usually justifies the dependency.
Constructor injection, one now() read per operation. Capture $now = $this->clock->now() at the top of a method and derive everything from that variable. Calling ->now() three times inside one transaction can straddle a second/minute boundary and produce off-by-one bugs in expiry checks.
Two bindings, two purposes. Persist and compare in UTC (UTCClock), render in the user's zone. Keep the clock UTC and do presentation-time conversion at the edge ($clock->now()->setTimezone($user->timezone)), or inject LocalizedClock::in($user->timezone) where the domain genuinely needs local time (store opening hours, "is it Monday for this customer?").
Testing with FrozenClock. Override the container binding per test rather than in phpunit.xml:
protected function setUp(): void
{
parent::setUp();
$this->clock = FrozenClock::at(new DateTimeImmutable('2024-03-01 09:00:00', new DateTimeZone('UTC')));
$this->app->instance(ClockInterface::class, $this->clock);
}
public function test_trial_expires(): void
{
// ... act
$this->clock->setTo($this->clock->now()->modify('+15 days'));
// ... assert expired
}
A small base-test helper ($this->travelClockTo('2024-12-31 23:59:59')) keeps this consistent across the suite. FrozenClock::withNowFrom(SystemClock::create()) is useful when you want "freeze at whatever now is" without hardcoding a date.
MinuteClock::wrapping() for cache keys and buckets. Anything keyed per-minute — rate-limit counters, "last seen" throttles, lock names — becomes trivially testable:
$bucket = MinuteClock::wrapping($this->clock)->now()->format('YmdHi');
WrappingClock::wrapping() for legacy and third-party code. If a vendor SDK or an existing internal service already exposes now(): DateTimeImmutable, wrap it instead of refactoring:
$this->app->instance(ClockInterface::class, WrappingClock::wrapping($legacyService));
It is also the cleanest way to bridge a Carbon-based now() provider into PSR-20 without touching business logic.
Queues and scheduler. Laravel's scheduler and dispatch()->delay() use the real system clock, and no clock adapter changes that. The useful pattern is a guard inside the job: inject the clock and re-check whether the job should still run (if ($this->clock->now() < $invoice->due_at) return;). That makes jobs idempotent and makes the guard testable by freezing time — while still letting the scheduler fire on real time.
Interop with Carbon/Eloquent. Your models still cast to Carbon. Convert at the boundary:
$model->created_at = CarbonImmutable::createFromInterface($this->clock->now());
Never let DateTimeImmutable from the clock leak into code that expects Carbon — createFromInterface() (or CarbonImmutable::instance()) keeps that seam explicit.
Time-sensitive policies. Business rules like "orders placed before 14:00 ship same day" belong in a service that takes ClockInterface and a timezone, not in a model accessor calling now().
FrozenClock is mutable. setTo() changes the instance. If you singleton() a frozen clock in a base test case, state leaks between tests. Bind it fresh per test via app()->instance() in setUp().travel(), freezeTime(), and Carbon::setTestNow() do not move this clock. They patch Carbon's test-now; SystemClock and FrozenClock ignore them entirely. Pick one strategy per test — freezing the injected clock — or you will get contradictory assertions. If you must mix, keep both in sync explicitly.SystemClock follows date_default_timezone_set(). Laravel sets that from config('app.timezone') at boot, so SystemClock::create() silently returns your app timezone, not UTC. If you want determinism, prefer UTCClock::create() for anything persisted or compared cross-system, and reserve SystemClock for human-facing output.$clock->now() in a singleton, a static, or a config value — in long-running queue workers and Octane that value goes stale for the life of the process.MinuteClock truncates sub-minute precision but keeps the timezone of the wrapped clock's value. Two MinuteClocks in different zones will produce different bucket keys — normalize to UTC before wrapping if keys are shared across regions.stella-maris/clock for psr/clock. If another dependency still pulls stella-maris/clock, your ClockInterface type-hint may be the wrong interface. Run composer why psr/clock / composer why stella-maris/clock when the container resolution "obviously correct" type fails to autowire.vendor:publish, no config, no alias. Everything is in AppServiceProvider. Consider binding a project-specific App\Clock\Clock interface that extends the PSR one so tests and refactors have a stable local name.dump(app(ClockInterface::class)::class) as the first line of a failing test tells you instantly whether the container gave you a frozen or real clock.Psr\Clock\ClockInterface yourself (an NTP-backed clock, a clock that reads a simulated time from a request header in staging). Bind conditionally by environment — UTCClock in production, FrozenClock when app()->runningUnitTests().now() in views and log lines alone.How can I help you explore Laravel packages today?