symfony/clock
Decouples your PHP applications from the system clock by injecting a ClockInterface, so you can get the current time, force a timezone, or pause execution with sleep, and swap in a mock clock for testing.
Install (usually already present). Recent Laravel releases pull symfony/clock in transitively — check with composer show symfony/clock before adding it explicitly. If it is missing:
composer require symfony/clock
Read these five files first (they are small, ~50–150 lines each):
| File | Why |
|---|---|
Symfony\Component\Clock\ClockInterface |
The whole contract: now(), sleep(), withTimeZone() |
Symfony\Component\Clock\NativeClock |
Production implementation |
Symfony\Component\Clock\MockClock |
Test implementation (modify(), setTo()) |
Symfony\Component\Clock\DatePoint |
DateTimeImmutable subclass with createFromTimestamp(), setMicroseconds() |
Symfony\Component\Clock\Clock |
Static facade (Clock::get() / Clock::set()) used by the test trait |
Also bookmark https://symfony.com/doc/current/components/clock.html — the README is a teaser, the docs page is the real reference.
Minimal wiring in Laravel. Register a default binding once, then inject everywhere:
// app/Providers/AppServiceProvider.php
use Symfony\Component\Clock\ClockInterface;
use Symfony\Component\Clock\NativeClock;
public function register(): void
{
$this->app->bind(ClockInterface::class, NativeClock::class);
}
// app/Actions/StartTrial.php
final class StartTrial
{
public function __construct(private ClockInterface $clock) {}
public function handle(User $user): void
{
$user->trial_ends_at = $this->clock->now()->modify('+30 days');
$user->save();
}
}
First use case to ship: the "disable after N days" / trial-expiry check. It is the highest-value, lowest-risk migration — one class, one test that runs in milliseconds instead of waiting 30 days.
public function test_trial_expires_after_thirty_days(): void
{
$clock = new MockClock('2025-01-01 00:00:00');
$this->app->instance(ClockInterface::class, $clock);
$user = User::factory()->create();
$this->post("/trial/{$user->id}/start");
$clock->sleep(60 * 60 * 24 * 30 + 1); // time travel, zero real delay
$this->assertTrue($user->fresh()->trialExpired($clock));
}
ClockInterface, never a concrete classAlways type-hint ClockInterface. That is the single decision that makes everything else (MockClock, UTC-pinned clock, decorators) swappable in one line of container config.
// production: UTC-pinned clock for internal services
$this->app->bind(ClockInterface::class, fn () => (new NativeClock())->withTimeZone('UTC'));
Note withTimeZone() returns a new instance — it is immutable, so it must be chained/assigned, never called for side effects.
instance()The cheapest, most explicit testing pattern. No trait magic, no globals:
protected function setUp(): void
{
parent::setUp();
$this->clock = new MockClock('2025-06-15 12:00:00');
$this->app->instance(ClockInterface::class, $this->clock);
}
Because MockClock::sleep() advances the mocked time rather than blocking, tests that simulate hours/days of drift run in microseconds.
Clock facade through the container (best of both worlds)ClockSensitiveTrait (from Symfony\Component\Clock\Test) manipulates the static Clock::set() — it will not touch a NativeClock instance already bound in your container. Bind through the facade instead of to a concrete class:
// AppServiceProvider
$this->app->bind(ClockInterface::class, fn () => Clock::get());
Then in a test:
use Symfony\Component\Clock\Test\ClockSensitiveTrait;
class ExpiryTest extends TestCase
{
use ClockSensitiveTrait;
public function test_grace_period(): void
{
$clock = static::mockTime('2025-01-01 00:00:00');
// container-resolved ClockInterface is now the same mock
$clock->sleep(3600);
}
}
Use bind (resolved on every resolution), not singleton — a singleton caches the first Clock::get() result and your later mockTime() will be ignored.
sleep() / usleep() inside jobs and workers// before
usleep(500_000);
// after
$this->clock->sleep(0.5);
This makes backoff/retry logic testable and gives you sub-second precision without sleep() integer truncation. It also makes it obvious when a job is intentionally delaying.
ClockInterface is a three-method interface — wrapping it is trivial and is the sanctioned way to add logging, metrics, or a "fixed offset" clock for staging:
final class LoggingClock implements ClockInterface
{
public function __construct(
private ClockInterface $inner,
private LoggerInterface $logger,
) {}
public function now(): DateTimeImmutable
{
$now = $this->inner->now();
$this->logger->debug('clock.now', ['at' => $now->format(DATE_ATOM)]);
return $now;
}
public function sleep(float|int $seconds): void { $this->inner->sleep($seconds); }
public function withTimeZone(string|\DateTimeZone $tz): static { /* ... */ }
}
DatePoint for value objectsWhen you need timestamp construction from an int (event sourcing, replaying logs), DatePoint::createFromTimestamp($ts) avoids (new DateTimeImmutable())->setTimestamp() gymnastics, and setMicroseconds() is available where the polyfill is not.
If you build DatePoint from a relative string (new DatePoint('tomorrow', $tz)), be aware that earlier 8.1.x patches kept the current time-of-day instead of resetting it; that was fixed in 8.1.9 (see Gotchas). Prefer passing an explicit date/time, or a reference plus a relative part, when the time-of-day matters.
created_at/updated_at aloneDo not override freshTimestamp() or getDateFormat() to route through the clock. Laravel's timestamps are a persistence concern; the clock is a domain concern. Inject ClockInterface into the action/service and set domain columns (trial_ends_at, locked_until, next_retry_at) explicitly.
now() returns DateTimeImmutable, not Carbon. This is the #1 friction point in a Laravel codebase. CarbonImmutable and DateTimeImmutable don't satisfy each other's type hints even though == comparisons work. Either standardise on DateTimeImmutable/DatePoint for domain code, or wrap at the boundary: CarbonImmutable::instance($clock->now()). Never mix hints within one class.
MockClock does not affect Carbon::now(), now(), or date(). Laravel's now() helper reads the real system clock. If your test freezes the injected clock but asserts on $model->created_at, the assertion uses real time. Keep the two worlds separate, or call Carbon::setTestNow($clock->now()) at the top of the test in addition if you truly need both aligned.
ClockSensitiveTrait::mockTime() returns the mock. The return type changed to ClockInterface in 7.1 — don't type-hint MockClock if you support a range of versions; call modify()/setTo() off the interface only if you actually assert on the concrete class, or keep a property and assert with instanceof.
MockClock::sleep() is time travel, not a pause. A job that calls $clock->sleep(5) to back off from a rate-limited API will not slow down in tests — the mock just jumps forward 5 seconds. That is usually the desired behaviour, but it means you cannot test "this request takes at least 5s" via the mock.
Negative durations. MockClock::sleep() behaviour for negative values was aligned with NativeClock in 7.3.8 / 6.4.30 / 8.0.0-RC1 — don't rely on it to rewind. Use $mock->modify('-1 hour') or $mock->setTo(...) to move backwards; those are explicit and stable.
Pin >= 8.1.9 if you use DatePoint or pre-1970 mock dates. v8.1.9 is a pure bug-fix release (no API change, nothing deprecated), but the four fixes are exactly the kind that produce silently wrong timestamps rather than exceptions:
new DatePoint() was one hour off during the DST fall-back hour, and so was DatePoint constructed from a reference that itself fell in the fall-back hour. If your test suite runs with Europe/London, America/New_York or any DST zone and freezes time inside the repeated hour, results flip depending on the patch version.DatePoint no longer keep the current time of day.MockClock::sleep() with dates before 1970 was broken (negative epoch arithmetic).Since these are wrong-value bugs, not crashes, bump the constraint (composer require symfony/clock:^8.1.9 or composer update symfony/clock) rather than assuming an old lockfile is fine. Verify with composer show symfony/clock — a transitive Laravel constraint can otherwise pin you below the fix.
withTimeZone() mutates nothing. $this->clock->withTimeZone('UTC') as a bare statement is a no-op. Always $this->clock = $this->clock->withTimeZone('UTC'); or chain at bind time. And remember it changes the timezone of the returned value, not the instant.
Never cache now() in a singleton. Long-running queue workers and Octane workers will hold a stale timestamp forever. Call $clock->now() at the point of use; NativeClock reads the system clock on every invocation and is effectively free.
Don't use Clock::get() in domain/application code. It is a service locator. It exists for legacy code and for the test trait. Constructor injection remains the thing that makes the abstraction worth having.
Boundary assertions are brittle. Asserting assertEquals($expected, $clock->now()->format('Y-m-d')) after a sleep() that crosses midnight will fail in surprising ways. Prefer assertions on relative state (trial_ends_at > now) or freeze at a comfortable time like 12:00:00 far from a date boundary.
Timezone in tests. Pass an explicit timezone to MockClock when assertions depend on offsets, and pin production clocks to UTC at the container level while rendering user-facing dates in the user's timezone downstream. Store UTC, display local, and the clock keeps that split honest.
sleep() precision on NativeClock. Sub-second floats work (it degrades to usleep()), but don't expect nanosecond guarantees — this is not a monotonic high-resolution timer. For WebSocket heartbeats or RT systems, this package is the wrong tool.
Check the Laravel–Symfony version matrix before pinning. Symfony 8 requires PHP ≥ 8.4; if you're on PHP 8.2/8.3 you'll be resolving a 7.x line (7.4.x is current LTS-ish). The API you actually use (now, sleep, withTimeZone, MockClock) has been stable across 6.3 → 8.x, so the risk is low, but composer why symfony/clock first saves a debugging session. Note that the 8.1.9 fixes are 8.1-branch fixes at time of writing — if you are pinned to 7.x/6.4, confirm the equivalent patch landed before relying on the corrected DatePoint DST behaviour.
How can I help you explore Laravel packages today?