Decoupling from System Time in Symfony: Making Time an Explicit Dependency

Cover image for Decoupling from System Time in Symfony: Making Time an Explicit Dependency

Why reading the system clock inside business logic creates nondeterministic behavior, and how an explicit Clock port makes expiry, validity, scheduling, persistence, and time-sensitive tests predictable.

Ever written a test like this?

PHP
$token = Token::issue();

sleep(2);

self::assertTrue($token->isExpired());

It passes locally. It sometimes fails in CI. Increasing the sleep makes the suite slower but still does not make the test trustworthy.

The real problem is not the assertion. The problem is that the business rule depends on a value the test does not control: the system clock.

Time often enters an application through innocent-looking code:

PHP
$now = new DateTimeImmutable();

or:

PHP
if ($subscription->expiresAt < new DateTimeImmutable()) {
    // ...
}

This looks harmless because no service was injected and no external API was called. But the code still read from the operating system. Time is an external input, just like a database result, a random number, or a payment-provider response.

While building a Symfony platform with expiring security tokens, subscription validity, scheduled cleanup, price periods, reports, and time-stamped domain events, I made current time an explicit dependency.

The result is a small Clock port in the domain, a Symfony adapter in Infrastructure, and a controlled clock in tests. Application handlers read one instant and pass it into policies and aggregates.

As a co-founder, I care about this because time-related defects are especially difficult to explain to customers. “Your access expired one second too early” or “the monthly report included the wrong day” sounds small technically, but it damages trust quickly.

In this post, I’ll show why direct system time is risky and how to reproduce my clock architecture in a generic Symfony application.

System Time Is a Hidden Global Dependency

Consider a password-reset challenge:

PHP
final class SecurityChallenge
{
    public function isExpired(): bool
    {
        return new DateTimeImmutable() > $this->expiresAt;
    }
}

The method signature claims it has no input:

PHP
isExpired(): bool

In reality, the result depends on at least two inputs:

Plain text
isExpired(expiresAt, operatingSystemTime): bool

The second input is invisible.

That hidden dependency creates several problems:

  • the same object can return different results without its state changing;

  • tests cannot choose the instant being evaluated;

  • boundary conditions require sleeping or global time hacks;

  • two calls made during one use case can observe different times;

  • application and database clocks can disagree;

  • timezone defaults can change behavior between environments;

  • and replaying a failed command may produce a different result.

The solution is not to stop using DateTimeImmutable. The solution is to stop using its implicit default as the source of “now.”

Constructing a Date Is Not the Same as Reading the Clock

This is perfectly valid:

PHP
$publishedAt = new DateTimeImmutable($request->publishedAt);

The instant came from explicit input.

This is also valid in a unit test:

PHP
$now = new DateTimeImmutable('2026-07-13T10:00:00+00:00');

The test chose the instant.

The problematic form is:

PHP
$now = new DateTimeImmutable();

or:

PHP
$now = new DateTimeImmutable('now');

Those forms ask global process state for an answer. In production business code, that question should go through an injected dependency.

The Architecture in One Diagram

Mermaid

Production and tests use different adapters, but both converge on the same domain port and application workflow.

Step 1: Define a Small Clock Port

The domain needs only one capability: provide the current immutable instant.

PHP
namespace App\Shared\Domain\Service;

use DateTimeImmutable;

interface Clock
{
    public function now(): DateTimeImmutable;
}

Keep this interface small. The domain does not need to know how Symfony freezes time, how the operating system synchronizes through NTP, or how a worker sleeps.

The interface lives in the stable layer that consumes the concept. The implementation lives outside the domain.

Avoid a static helper:

PHP
final class Time
{
    public static function now(): DateTimeImmutable
    {
        return new DateTimeImmutable();
    }
}

That only gives the hidden global dependency a nicer name. A static call is still difficult to substitute locally and makes parallel tests more fragile.

Step 2: Adapt Symfony’s Clock Component

Infrastructure connects my port to Symfony:

PHP
namespace App\Shared\Infrastructure\Clock;

use DateTimeImmutable;
use App\Shared\Domain\Service\Clock;
use Symfony\Component\Clock\ClockInterface;

final readonly class SymfonyClock implements Clock
{
    public function __construct(
        private ClockInterface $clock,
    ) {}

    public function now(): DateTimeImmutable
    {
        return DateTimeImmutable::createFromInterface(
            $this->clock->now(),
        );
    }
}

The domain is now independent of Symfony, while Infrastructure can use Symfony’s clock ecosystem.

With service autowiring enabled and one implementation of the application clock port, Symfony can inject it automatically. If your container cannot infer the implementation, add one explicit alias:

YAML
services:
    App\Shared\Domain\Service\Clock:
        alias: App\Shared\Infrastructure\Clock\SymfonyClock

The rest of the application depends only on Clock.

Step 3: Capture One Instant per Use Case

Inject the clock into the application handler or application service that begins the operation:

PHP
#[AsMessageHandler(bus: 'command.bus')]
final readonly class VerifyEmailAddressHandler
{
    public function __construct(
        private SecurityChallengeRepository $challenges,
        private UserRepository $users,
        private Clock $clock,
        private EventDispatcher $events,
    ) {}

    public function __invoke(VerifyEmailAddress $command): void
    {
        $now = $this->clock->now();
        $challenge = $this->challenges->getByToken($command->token);
        $user = $this->users->get($challenge->userId);

        $challenge->consume($now);
        $user->verifyEmail($now);

        $this->challenges->save($challenge);
        $this->users->save($user);
        $this->events->dispatch([
            ...$challenge->releaseEvents(),
            ...$user->releaseEvents(),
        ]);
    }
}

Capturing once matters.

This version is weaker:

PHP
$challenge->consume($this->clock->now());
$user->verifyEmail($this->clock->now());
$event = new EmailVerified($this->clock->now());

Those timestamps may differ by microseconds or cross an expiry, second, day, or month boundary. One business operation should usually have one authoritative instant.

I treat $now as part of the use-case context and reuse it for:

  • policy decisions;

  • aggregate transitions;

  • persistence queries;

  • domain events;

  • and diagnostic logs.

For a genuinely long-running workflow, later milestones may need new clock readings. Name them according to their meaning—$startedAt, $completedAt, or $failedAt—instead of scattering anonymous now() calls.

Step 4: Pass Time into Aggregates Instead of Injecting a Clock

I do not normally inject Clock into entities.

An aggregate method receives the instant explicitly:

PHP
final class SecurityChallenge
{
    public function consume(DateTimeImmutable $now): void
    {
        if ($this->consumedAt instanceof DateTimeImmutable) {
            throw new TokenAlreadyUsed();
        }

        if ($now > $this->expiresAt) {
            throw new TokenExpired();
        }

        $this->consumedAt = $now;
    }

    public function isExpiredAt(DateTimeImmutable $now): bool
    {
        return $now > $this->expiresAt;
    }
}

The signature now tells the truth:

PHP
isExpiredAt(DateTimeImmutable $now): bool

The entity is deterministic. Given the same state and the same instant, it produces the same answer.

This also makes the boundary semantics visible. In this example:

PHP
$now > $this->expiresAt

The token remains valid exactly at expiresAt and becomes invalid immediately after it. If the business wants expiry to be exclusive, use >=. The clock abstraction does not decide that rule; it makes the rule testable.

Step 5: Create Time-Based Objects from an Explicit Origin

Issuing a challenge also receives an explicit time:

PHP
final class SecurityChallenge
{
    private const string DURATION = 'PT15M';

    public static function issue(
        UserId $userId,
        PlainToken $token,
        DateTimeImmutable $issuedAt,
    ): self {
        $expiresAt = $issuedAt->add(
            new DateInterval(self::DURATION),
        );

        return new self(
            userId: $userId,
            tokenHash: TokenHash::fromPlainToken($token),
            expiresAt: $expiresAt,
        );
    }
}

The application service obtains issuedAt from the clock and passes it in:

PHP
$now = $this->clock->now();

$challenge = SecurityChallenge::issue(
    userId: $userId,
    token: $this->tokenGenerator->generate(),
    issuedAt: $now,
);

Do not let the factory call the system clock internally. A static constructor is still domain code and should remain deterministic.

Step 6: Pass Time into Policies

Policies often combine repositories and validity rules:

PHP
final readonly class SubscriptionPolicy
{
    public function __construct(
        private SubscriptionRepository $subscriptions,
        private OrganizationMemberRepository $members,
    ) {}

    public function ensureCanStartIndividualSubscription(
        UserId $userId,
        DateTimeImmutable $now,
    ): void {
        if ($this->subscriptions->findRunningByUser($userId, $now) instanceof Subscription) {
            throw SubscriptionRuleViolated::alreadySubscribed();
        }

        if ($this->members->hasActiveSubscription($userId, $now)) {
            throw SubscriptionRuleViolated::coveredByOrganization();
        }
    }
}

The policy does not decide what “current” means. Its caller supplies the instant.

This is especially useful when evaluating historical or future state:

PHP
$policy->hasAccessAt($userId, $invoiceDate);

Once time is explicit, the same business rule can answer “did this user have access when the invoice was issued?” without temporarily changing a global clock.

Step 7: Model Validity as a Value Object

A clock answers “what time is it?” A value object answers “does this instant belong to this period?”

PHP
final readonly class DateRange
{
    public function __construct(
        public DateTimeImmutable $start,
        public ?DateTimeImmutable $end,
    ) {
        if ($end instanceof DateTimeImmutable && $end <= $start) {
            throw new InvalidDateRange();
        }
    }

    public function contains(DateTimeImmutable $instant): bool
    {
        if ($instant < $this->start) {
            return false;
        }

        return ! $this->end instanceof DateTimeImmutable
            || $instant <= $this->end;
    }
}

Keep these responsibilities separate:

Mermaid

This avoids rebuilding date comparisons in controllers, SQL adapters, and entities with slightly different inclusivity rules.

Step 8: Bind the Same Time into Database Queries

This query is convenient but introduces another clock:

SQL
WHERE valid_from <= CURRENT_TIMESTAMP
  AND (valid_until IS NULL OR valid_until >= CURRENT_TIMESTAMP)

Now PHP makes decisions using the application host clock while PostgreSQL uses the database host clock. Usually they are close. “Usually” is not a business invariant.

Prefer binding the application’s chosen instant:

PHP
public function findActivePrices(): array
{
    $now = $this->clock->now();

    return $this->connection->createQueryBuilder()
        ->select('*')
        ->from('prices')
        ->where("(validity->>'start')::timestamptz <= :now")
        ->andWhere(<<<'SQL'
            (validity->>'end') IS NULL
            OR (validity->>'end')::timestamptz >= :now
        SQL)
        ->setParameter('now', $now->format(DATE_ATOM))
        ->executeQuery()
        ->fetchAllAssociative();
}

Even better, accept the instant from the query handler when several repositories or read models must agree:

PHP
public function findActivePricesAt(DateTimeImmutable $now): array;

Database-generated timestamps can still be useful for purely technical audit columns or database-owned operations. The rule is not “never use database time.” The rule is “do not let two uncoordinated clocks decide one business outcome.”

Step 9: Replace the Clock in the Test Container

The test implementation wraps Symfony’s MockClock:

PHP
#[AsAlias(Clock::class, when: 'test')]
final class TestClock implements Clock
{
    private MockClock $clock;

    public function __construct()
    {
        $this->setNow(
            new DateTimeImmutable('2026-07-13T10:00:00+00:00'),
        );
    }

    public function setNow(DateTimeImmutable $now): void
    {
        $this->clock = new MockClock($now);
    }

    public function now(): DateTimeImmutable
    {
        return DateTimeImmutable::createFromInterface(
            $this->clock->now(),
        );
    }
}

The test-only alias replaces the production adapter without changing application code:

PHP
#[AsAlias(Clock::class, when: 'test')]

Tests can now choose time instantly. No sleeping, no process-wide monkey patching, and no dependence on how busy the CI runner is.

Reset the clock before every scenario so one test’s time travel cannot leak into another.

Step 10: Unit-Test Exact Boundaries

Because aggregates receive time explicitly, unit tests do not even need a clock service:

PHP
final class SecurityChallengeTest extends TestCase
{
    public function testExpiryBoundary(): void
    {
        $challenge = SecurityChallenge::issue(
            userId: new UserId(),
            token: new PlainToken('verification-token'),
            issuedAt: new DateTimeImmutable(
                '2026-07-13T10:00:00+00:00',
            ),
        );

        self::assertFalse($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:14:59+00:00'),
        ));
        self::assertFalse($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:15:00+00:00'),
        ));
        self::assertTrue($challenge->isExpiredAt(
            new DateTimeImmutable('2026-07-13T10:15:01+00:00'),
        ));
    }
}

This test finishes immediately and documents the inclusivity decision exactly.

Useful temporal test partitions include:

  • one instant before the start;

  • exactly at the start;

  • inside the interval;

  • exactly at the end;

  • one instant after the end;

  • an open-ended interval;

  • daylight-saving or timezone conversion when relevant;

  • and a replay after the operation was already completed.

Step 11: Move Time Explicitly in Behat Scenarios

Application tests expose the controlled clock through business language:

PHP
final readonly class TimeContext implements Context
{
    public function __construct(
        private TestClock $clock,
    ) {}

    #[Given('current time is :time')]
    public function currentTimeIs(string $time): void
    {
        $this->clock->setNow(new DateTimeImmutable($time));
    }
}

The scenario can move across a boundary without waiting:

Plain text
Scenario: An expired security challenge cannot be consumed
    Given current time is "2026-07-13T10:00:00+00:00"
    And an email-verification challenge exists
    And current time is "2026-07-13T10:16:00+00:00"
    When the security challenge is consumed
    Then it should be rejected because the token expired

The command handler sees the same Clock port it sees in production. Only its adapter changed.

Do not hide an important date in the step definition. If time changes the outcome, show it in the scenario.

Step 12: Test Cleanup Windows without Sleeping for Seven Days

Scheduled cleanup is a good example of why time must be controllable.

PHP
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CleanupUnconfirmedUsersHandler
{
    private const string CONFIRMATION_PERIOD = 'P7D';

    public function __construct(
        private UserRepository $users,
        private Clock $clock,
    ) {}

    public function __invoke(CleanupUnconfirmedUsers $command): int
    {
        $now = $this->clock->now();
        $registeredBefore = $now->sub(
            new DateInterval(self::CONFIRMATION_PERIOD),
        );

        $users = $this->users->findUnconfirmedWithExpiredVerification(
            registeredBefore: $registeredBefore,
            expiredAt: $now,
            limit: $command->limit,
        );

        foreach ($users as $user) {
            $this->users->remove($user);
        }

        return count($users);
    }
}

The application scenario can test both sides of the seven-day boundary:

Plain text
Given current time is "2026-07-08T10:00:00+00:00"
And the following unconfirmed accounts exist:
    | email                    | registeredAt             |
    | older@example.test       | 2026-07-01T09:59:59+00:00 |
    | exact@example.test       | 2026-07-01T10:00:00+00:00 |
    | recent@example.test      | 2026-07-01T10:00:01+00:00 |
When expired unconfirmed accounts are cleaned up
Then 2 accounts should be deleted
But "recent@example.test" should remain

No wall-clock waiting is involved. The scenario is fast enough to run on every commit.

Step 13: Separate Scheduler Time from Business Time

A scheduler answers when to trigger a command:

PHP
#[AsCronTask(
    '30 2 * * *',
    timezone: 'Africa/Lubumbashi',
    schedule: 'cleanup_unconfirmed_users',
)]
final readonly class CleanupUnconfirmedUsersConsole
{
    public function __construct(private CommandBus $commands) {}

    public function __invoke(): int
    {
        $this->commands->handle(new CleanupUnconfirmedUsers());

        return Command::SUCCESS;
    }
}

The command handler still asks the injected clock what instant it is.

This separation means:

  • the command can run manually without lying about time;

  • the scheduler configuration can be tested independently;

  • the use case remains deterministic under a test clock;

  • and a delayed worker evaluates the time semantics intentionally.

Do not put the business rule directly in the cron command. The console is a trigger, not the owner of expiry.

Step 14: Distinguish Instants from Business Calendar Time

An instant and a calendar date are not the same concept.

Store and exchange instants with an explicit offset, preferably normalized consistently:

Plain text
2026-07-13T10:00:00+00:00

Convert to a business timezone only when the rule is calendar-based:

PHP
private function previousMonth(DateTimeImmutable $now): DateRange
{
    $firstDayOfThisMonth = $now->setTimezone(
        new DateTimeZone('Africa/Lubumbashi'),
    )
        ->modify('first day of this month')
        ->setTime(0, 0);

    return new DateRange(
        start: $firstDayOfThisMonth->modify('-1 month'),
        end: $firstDayOfThisMonth->modify('-1 microsecond'),
    );
}

This example uses an inclusive DateRange, so its end is the final representable instant of the previous month. If your range uses the often preferable half-open convention—start <= instant < end—keep firstDayOfThisMonth as the exclusive end instead. Whichever convention you choose, encode it once and test the boundary.

The previous billing month depends on a business timezone. A token lasting fifteen minutes usually depends on elapsed instants, not the calendar name of the hour.

Make the timezone part of the rule or configuration. Do not inherit date_default_timezone_get() accidentally from a laptop, container image, or server.

Step 15: Use a Monotonic Clock for Measuring Duration

Wall-clock time can jump. NTP corrections, manual changes, and virtualization can move it forward or backward.

Business timestamps need civil time:

PHP
$occurredAt = $clock->now();

Performance measurement needs a monotonic source:

PHP
$started = hrtime(true);

$result = $service->execute();

$durationNanoseconds = hrtime(true) - $started;

Do not use the domain clock to benchmark a query, and do not store hrtime() values as business timestamps. They answer different questions.

Mermaid

Step 16: Decide What Time an Asynchronous Command Means

Suppose an HTTP request dispatches a message at 10:00, but the worker executes it at 10:05.

Which time should the business rule use?

There are two valid answers.

If the rule concerns execution time, let the handler read the worker clock:

PHP
$processedAt = $this->clock->now();

If the rule concerns when the user acted, include that instant in the message:

PHP
final readonly class ConfirmOrder
{
    public function __construct(
        public OrderId $orderId,
        public DateTimeImmutable $requestedAt,
    ) {}
}

Do not accidentally use worker time for a request-time rule. Also do not call a stale message timestamp “now.” Name each instant according to its meaning.

Retries make this distinction even more important. A command replayed tomorrow should not silently reinterpret yesterday’s customer action unless that is the intended policy.

Step 17: Migrate an Existing Codebase Incrementally

You do not need to refactor every date in one pull request.

Start by finding implicit current-time reads:

Bash
rg "new DateTimeImmutable\\(\\)|new DateTimeImmutable\\('now'\\)|new DateTime\\(\\)|CURRENT_TIMESTAMP|NOW\\("

Then classify each result:

  • parsing explicit input—leave it as date construction;

  • creating a known test fixture—leave it explicit;

  • reading current business time—replace it with Clock;

  • measuring duration—use a monotonic source;

  • database-owned technical timestamp—review separately;

  • scheduled trigger timezone—make the timezone explicit.

Refactor from the application boundary inward:

  1. Introduce the Clock interface.

  2. Add the Symfony production adapter.

  3. Inject it into one handler.

  4. Capture $now once.

  5. Pass the instant into policies, entities, repositories, and events.

  6. Add the controlled test implementation.

  7. Replace sleep-based tests with explicit boundary cases.

This approach keeps the change small and reveals which methods were secretly time-dependent.

Common Time-Dependency Mistakes

Hiding new DateTimeImmutable() behind a Static Helper

The dependency remains global. Inject a clock port instead.

Injecting the Clock into Every Entity

Usually, the application layer should choose the instant and pass it into the aggregate. This keeps entities deterministic and signatures honest.

Calling now() Repeatedly in One Use Case

Capture once unless the operation intentionally models multiple milestones.

Using sleep() in Tests

Sleeping makes the suite slower and still leaves timing nondeterministic. Move a test clock instead.

Freezing Time Globally

Global monkey patches can leak between tests and interfere with parallel execution. Prefer dependency injection and scenario-local reset.

Mixing PHP Time and Database Time

Bind the selected application instant when both layers participate in the same business decision.

Ignoring Boundary Inclusivity

Decide whether expiresAt itself is valid. Test immediately before, exactly at, and immediately after the boundary.

Depending on the Server’s Default Timezone

Use explicit offsets for instants and explicit business timezones for calendar rules and schedules.

Confusing Event Time and Processing Time

An asynchronous message may have requestedAt, occurredAt, receivedAt, and processedAt. Give each one a precise name.

Using Wall Time for Performance Measurement

Use a monotonic clock such as hrtime() for elapsed duration.

Wrapping Up

System time is external state. Reading it directly inside business logic creates a hidden dependency that makes behavior harder to reproduce, test, and reason about.

  • A small domain Clock port makes current time explicit.

  • A Symfony infrastructure adapter connects that port to the production clock.

  • A controlled test adapter replaces it without changing application code.

  • Application handlers capture one authoritative instant per use case.

  • Aggregates and policies receive time through method parameters.

  • Validity value objects own interval and boundary semantics.

  • Database queries bind the same instant instead of silently consulting another clock.

  • Unit tests exercise exact temporal boundaries without sleeping.

  • Behat scenarios move time using business-readable steps.

  • Cleanup commands can test days or weeks of behavior instantly.

  • Scheduler timezone and business-time evaluation remain separate concerns.

  • Calendar rules convert explicitly to their business timezone.

  • Monotonic time measures duration, while wall time records business events.

  • Asynchronous messages distinguish action time from processing time.

The biggest benefit is not faster tests, although the tests become much faster. The real benefit is that time stops being ambient magic.

Once time is an explicit input, a rule can be evaluated today, tomorrow, at an expiry boundary, or during a replay—and the code will tell me exactly which instant it used.

That is what makes time-sensitive behavior trustworthy.

Happy coding!

Related writing