DEV Community

Cover image for Time Is a Dependency: Injecting a Clock Into Your PHP Domain
Gabriel Anhaia
Gabriel Anhaia

Posted on

Time Is a Dependency: Injecting a Clock Into Your PHP Domain


You have a subscription. It expires 30 days after it starts. The test you wrote checks that a 31-day-old subscription is expired, and it passes on your machine. Six weeks later it fails in CI for no reason anyone can name. Nobody changed the code. The test was green; now it's red.

What changed is the date. The Subscription class called new DateTimeImmutable() inside isExpired(), so the test could only ever check "expired relative to right now." The fixture pinned a start date in the past, the gap to "now" grew every day, and one morning the gap crossed a boundary the test assumed. The clock moved and your test moved with it.

This is the tell of a hidden dependency. isExpired() reads from the system clock, and the system clock is global mutable state you never passed in. The method's output depends on something outside its arguments. That makes it untestable in the honest sense: you cannot ask it about a specific moment, only about the moment the test happens to run.

What new DateTimeImmutable() actually couples you to

A method that calls new DateTimeImmutable() has a dependency on the wall clock the same way a method that calls file_get_contents() has a dependency on the filesystem. You would not put a raw file read in the middle of a domain rule. The clock sneaks in because PHP makes reading time look free.

<?php

declare(strict_types=1);

final class Subscription
{
    public function __construct(
        private readonly \DateTimeImmutable $startedAt,
        private readonly int $termDays,
    ) {}

    public function isExpired(): bool
    {
        $now = new \DateTimeImmutable();
        $end = $this->startedAt->modify(
            "+{$this->termDays} days"
        );
        return $now > $end;
    }
}
Enter fullscreen mode Exit fullscreen mode

The rule is correct. The problem is the third line. isExpired() takes no time argument, so the only way to test the "expired" branch is to construct a subscription old enough that today's date is past the end. That fixture rots. Worse, you cannot test the boundary precisely. "Is a subscription expired one second after its term ends" is a question this code cannot answer in a test, because you cannot stand on that second.

Time is an input. Treat it like one.

The Clock port

Define an interface in the domain that states the one thing the domain needs from time: the current moment.

<?php

declare(strict_types=1);

namespace App\Domain\Shared;

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

One method. It lives next to your value objects, in the domain, and imports nothing from a framework. This is a port in the hexagonal sense: the domain declares what it wants, and infrastructure supplies it later.

Now the subscription asks for the time instead of reaching for it.

<?php

declare(strict_types=1);

namespace App\Domain\Billing;

use App\Domain\Shared\Clock;

final class Subscription
{
    public function __construct(
        private readonly \DateTimeImmutable $startedAt,
        private readonly int $termDays,
    ) {}

    public function isExpired(Clock $clock): bool
    {
        $end = $this->startedAt->modify(
            "+{$this->termDays} days"
        );
        return $clock->now() > $end;
    }
}
Enter fullscreen mode Exit fullscreen mode

Passing the Clock into the method is one option, and it reads well for a pure query like this. When an entity needs the time in several places, inject the clock once through the use case instead and pass the resolved now() value into the entity. Both keep the domain honest. The rule is the same: the moment comes from outside.

The SystemClock adapter

Production needs the real wall clock. That is one small class in the infrastructure layer.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Clock;

use App\Domain\Shared\Clock;

final class SystemClock implements Clock
{
    public function now(): \DateTimeImmutable
    {
        return new \DateTimeImmutable();
    }
}
Enter fullscreen mode Exit fullscreen mode

This is the only place in the whole codebase that calls new \DateTimeImmutable() with no argument. Every other line that needs the current time goes through the port. You can grep src/Domain and src/Application for new \DateTimeImmutable() and treat any hit as a bug. One CI check guards the seam.

If you want a timezone-aware production clock, that is the file to change:

public function now(): \DateTimeImmutable
{
    return new \DateTimeImmutable(
        'now',
        new \DateTimeZone('UTC'),
    );
}
Enter fullscreen mode Exit fullscreen mode

The domain never learns about timezones. It asks for the current moment and gets one.

The FrozenClock for tests

Here is the payoff. A test clock returns a moment you chose, and it never moves.

<?php

declare(strict_types=1);

namespace App\Tests\Support;

use App\Domain\Shared\Clock;

final class FrozenClock implements Clock
{
    public function __construct(
        private \DateTimeImmutable $now,
    ) {}

    public static function at(string $iso): self
    {
        return new self(new \DateTimeImmutable($iso));
    }

    public function now(): \DateTimeImmutable
    {
        return $this->now;
    }

    public function advance(string $interval): void
    {
        $this->now = $this->now->add(
            new \DateInterval($interval)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the subscription test states the exact moment it cares about, and the assertion means what it says.

public function test_subscription_expires_after_term(): void
{
    $sub = new Subscription(
        startedAt: new \DateTimeImmutable('2026-01-01'),
        termDays: 30,
    );

    $before = FrozenClock::at('2026-01-30T23:59:59Z');
    $after = FrozenClock::at('2026-01-31T00:00:01Z');

    self::assertFalse($sub->isExpired($before));
    self::assertTrue($sub->isExpired($after));
}
Enter fullscreen mode Exit fullscreen mode

The test pins both sides of the boundary. It will pass today, it will pass next year, it will pass when someone reruns the suite on a laptop with a wrong system clock. The result no longer depends on the day you run it.

The advance method earns its keep when you test something that elapses. Charge a subscription, move the clock forward 31 days, assert the renewal fired.

public function test_renewal_charges_after_term(): void
{
    $clock = FrozenClock::at('2026-01-01T00:00:00Z');
    $billing = new BillingService($clock, $gateway);

    $sub = $billing->start('customer-1');
    $clock->advance('P31D');
    $billing->runRenewals();

    self::assertCount(1, $gateway->charges());
}
Enter fullscreen mode Exit fullscreen mode

No sleep(). No fixture dated relative to today. You move time by hand and watch the system react.

Wiring it at the composition root

The domain and the use cases depend on the Clock interface. Only the container knows which clock is real.

$c->set(Clock::class, fn() => new SystemClock());

$c->set(BillingService::class, fn(C $c) => new BillingService(
    $c->get(Clock::class),
    $c->get(PaymentGateway::class),
));
Enter fullscreen mode Exit fullscreen mode

In the test bootstrap you bind FrozenClock instead, or you construct the service directly with one in a unit test. The production code path never sees a frozen clock, and the test path never touches the wall clock. The two never overlap.

The objection: PHP has ClockInterface already

It does. PSR-20 standardized Psr\Clock\ClockInterface with a single now(): DateTimeImmutable method, and Symfony ships a Symfony\Component\Clock\Clock with MockClock and a ClockAwareTrait. If you are on a framework that gives you these, use them. The domain port can extend Psr\Clock\ClockInterface so your code speaks the standard while keeping a domain-local name:

<?php

declare(strict_types=1);

namespace App\Domain\Shared;

use Psr\Clock\ClockInterface;

interface Clock extends ClockInterface
{
}
Enter fullscreen mode Exit fullscreen mode

The point was never to avoid existing tools. The point is that the current time enters your domain through a named seam instead of a hardcoded call. Whether the adapter is your three-line SystemClock or Symfony's component is a wiring detail.

What you get back

Tests stop depending on the calendar. A suite that pins time never flakes when a month rolls over or a leap day arrives.

Boundaries become testable. "One second before expiry" and "one second after" are two lines, not two impossible fixtures.

The dependency is visible. A reader of Subscription sees Clock in the signature and knows the class reads the time. Nothing is hidden in a method body. When you later add audit timestamps, retention windows, or a "trial ends in N days" banner, the time source is already a parameter you control.

new DateTimeImmutable() in a domain method is not a small convenience. It is an undeclared dependency on global state, and it costs you every time the calendar moves under a test. Name it, inject it, freeze it in tests.


If this was useful

The Clock port is one of the smaller seams the book treats as a first-class citizen, alongside the repository, the event bus, and the payment gateway. Decoupled PHP works through the same discipline at every layer: name the dependency, push the concrete thing to the edge, and keep the domain something you can test without booting a framework. If your test suite has ever gone red because a month changed, the chapter on ports is for you.

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)