DEV Community

Cover image for The Symfony Clock Component: Testable Time Without Mocking DateTime
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Symfony Clock Component: Testable Time Without Mocking DateTime


You've written this test. A token expires after 15 minutes, and you need to prove it. So you reach for sleep(900) and immediately regret it, because now the suite takes 15 minutes longer. Or you find some library that overrides time() in a namespace with a monkey-patch, and it works until someone runs the test in isolation and it breaks.

The root problem is that new \DateTimeImmutable() reads the system clock directly. That call is a hidden global dependency buried inside your domain code, and you can't control a dependency you never injected. The fix is to stop calling the clock and start receiving it. Symfony's Clock component, built on PSR-20, does exactly that.

The interface that changes everything

PSR-20 defines one method. That's the whole standard.

<?php

namespace Psr\Clock;

interface ClockInterface
{
    public function now(): \DateTimeImmutable;
}
Enter fullscreen mode Exit fullscreen mode

Symfony ships three implementations of it. NativeClock reads the real system time. MonotonicClock gives you a clock that never jumps backwards (good for measuring durations). MockClock is the one that makes tests deterministic: it returns whatever moment you tell it to, and it only moves when you move it.

Install the component:

composer require symfony/clock
Enter fullscreen mode Exit fullscreen mode

Injecting time into the domain

Here's a service that decides whether a password-reset token is still valid. The naive version reads the clock inline.

<?php

namespace App\Auth;

final class TokenValidator
{
    public function isExpired(Token $token): bool
    {
        $now = new \DateTimeImmutable();
        return $token->issuedAt->modify('+15 minutes') < $now;
    }
}
Enter fullscreen mode Exit fullscreen mode

To test the expired branch you have to wait, or fake the token's issuedAt to something in the past, which tests the wrong thing. Inject the clock instead.

<?php

namespace App\Auth;

use Psr\Clock\ClockInterface;

final class TokenValidator
{
    public function __construct(
        private readonly ClockInterface $clock,
    ) {
    }

    public function isExpired(Token $token): bool
    {
        $deadline = $token->issuedAt->modify('+15 minutes');
        return $deadline < $this->clock->now();
    }
}
Enter fullscreen mode Exit fullscreen mode

The domain no longer knows what time it is. It asks. Symfony autowires ClockInterface to NativeClock in production with zero config, so the running app behaves exactly as before. The difference only shows up in tests.

Freezing and advancing time in tests

MockClock takes a starting point and stays there until you tell it to move. That means you can pin "now" to a fixed instant, run your assertion, then push time forward by an exact amount.

<?php

namespace App\Tests\Auth;

use App\Auth\Token;
use App\Auth\TokenValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;

final class TokenValidatorTest extends TestCase
{
    public function testTokenIsValidBeforeDeadline(): void
    {
        $clock = new MockClock('2026-01-01 12:00:00');
        $token = new Token(issuedAt: $clock->now());

        $validator = new TokenValidator($clock);

        $clock->sleep(14 * 60); // 14 minutes later
        self::assertFalse($validator->isExpired($token));
    }

    public function testTokenExpiresAfterFifteenMinutes(): void
    {
        $clock = new MockClock('2026-01-01 12:00:00');
        $token = new Token(issuedAt: $clock->now());

        $validator = new TokenValidator($clock);

        $clock->sleep(16 * 60); // 16 minutes later
        self::assertTrue($validator->isExpired($token));
    }
}
Enter fullscreen mode Exit fullscreen mode

No sleep() that blocks the process. MockClock::sleep() advances the mock's internal clock without touching the real one, so 16 minutes of simulated time passes in microseconds. The test is deterministic: it gives the same answer on a fast CI box and a loaded laptop.

You can also move time with a DateInterval or set an absolute moment:

<?php

$clock = new MockClock('2026-01-01 00:00:00');

$clock->sleep(3600);          // +1 hour, seconds
$clock->sleep(1.5);           // +1.5 seconds, sub-second ok
$clock->modify('+2 days');    // relative jump
$clock->now();                // 2026-01-03 01:00:01.5
Enter fullscreen mode Exit fullscreen mode

Sub-second precision matters when you test rate limiters or debounce logic. MockClock keeps microseconds, so a test that advances by 0.25 seconds behaves correctly.

The ClockAwareTrait for less boilerplate

If a class needs the clock but you don't want a constructor argument for it, Symfony ships ClockAwareTrait. It adds a now() method and a setter that the container wires automatically.

<?php

namespace App\Billing;

use Symfony\Component\Clock\ClockAwareTrait;

final class InvoiceScheduler
{
    use ClockAwareTrait;

    public function isPastDue(Invoice $invoice): bool
    {
        return $invoice->dueDate < $this->now();
    }
}
Enter fullscreen mode Exit fullscreen mode

In a test, call setClock() with a MockClock and you control time the same way. The trait is a convenience, not a different mechanism. Under the hood it's still a ClockInterface dependency, so the same testing story applies.

The global now() helper, and why you should still inject

The component ships a function too. Symfony\Component\Clock\now() returns the current time from a globally configured clock.

<?php

use function Symfony\Component\Clock\now;

$timestamp = now();               // DateTimeImmutable
$soon = now()->modify('+5 min');
Enter fullscreen mode Exit fullscreen mode

There's a matching Clock::set() that swaps the global clock, which is handy for tests against code you can't refactor yet:

<?php

use Symfony\Component\Clock\Clock;
use Symfony\Component\Clock\MockClock;

Clock::set(new MockClock('2026-06-01 09:00:00'));
// every now() call in the process now returns this frozen time
Enter fullscreen mode Exit fullscreen mode

Reach for the global helper when you're retrofitting an old codebase that calls new \DateTimeImmutable() in a hundred places and you want a bridge before you inject properly. For new code, prefer the injected ClockInterface. A global you mutate in one test can bleed into the next if you forget to reset it, and explicit constructor dependencies never have that problem.

Timezones stay honest

MockClock accepts a timezone, and withTimeZone() returns a new clock without mutating the original. That lets you test the nasty cases: a job that runs "at midnight" for a user in Tokyo while your server thinks in UTC.

<?php

use Symfony\Component\Clock\MockClock;

$utc = new MockClock('2026-03-29 23:30:00', 'UTC');
$berlin = $utc->withTimeZone('Europe/Berlin');

$utc->now()->format('H:i');     // 23:30
$berlin->now()->format('H:i');  // 01:30 (next day, DST shift)
Enter fullscreen mode Exit fullscreen mode

Because the clock is a dependency, you can pin both the instant and the zone. Daylight-saving bugs, the ones that surface twice a year and ruin a weekend, become a plain unit test you write once.

What you actually gained

The mechanical win is obvious: no sleep(), no monkey-patching, no fragile time libraries. The deeper win is that time stopped being a hidden global and became a named input. Your TokenValidator reads like a rule now: a token is expired when its deadline is behind the current moment. Whether that moment is real or frozen is somebody else's decision, made at the edge of the system.

That's the pattern worth taking away. Anything your domain reads from the outside world is a dependency in disguise: the clock, a random seed, the filesystem, an HTTP client. Name it, put an interface in front of it, and let the caller decide what to pass. Deterministic tests fall out for free once you stop reaching for globals.

What's the ugliest time-related test hack still living in your suite? A sleep(), a hard-coded future date, a monkey-patched time()? Those are the first ones a clock injection pays off.


If this was useful

Injecting the clock is a small version of a bigger discipline: keeping the things your domain can't control (time, randomness, I/O) at the edge, behind interfaces the framework wires up, so the core stays pure and testable. That boundary is the whole subject of Decoupled PHP: what belongs in the domain, what belongs in the framework, and how to keep the two from bleeding into each other as an app grows.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)