Index

Design Before the Pattern Name

Global State Makes Local Reasoning Impossible

A repository test created a shipment two days before now, then counted shipments between the start and end of the current month. It passed most days. During the first two days of a month, “two days ago” belonged to the previous month and the count became zero.

Nothing in the test name mentioned a calendar boundary. Nothing in the method signature mentioned time. The same test logic produced a different result because its generated data moved with one more input: the instant at which the test ran.

The immediate correction froze the clock in the middle of the month. That stabilized the example. It did not make the dependency local.

A parameterless call can still have inputs

Consider a pricing rule:

final class Promotion
{
    public function applies(): bool
    {
        return now()->isBefore($this->endsAt);
    }
}

The visible input is the promotion object. The effective input is:

applies(promotion, process clock)

Any caller may receive a different answer without changing the object or the call. A test can alter that answer for every other caller by changing the process clock.

Time is not exceptional here. Locale, authenticated user, tenant, feature flags, environment variables, and mutable singletons can all become invisible inputs. The clock is simply an unusually clear example because it keeps moving even when nobody mutates it deliberately.

Local reasoning means that the code at the call site exposes enough of the decision’s inputs to explain its result. Ambient state breaks that property.

Freezing time contains a test, not the design

A process-wide test clock is useful when old code already reads ambient time:

Carbon::setTestNow('2030-01-15 12:00:00');

try {
    // exercise legacy code
} finally {
    Carbon::setTestNow();
}

The finally matters. If an assertion or setup step throws before cleanup, the frozen instant otherwise leaks into whichever test happens to run next. The second test then fails because of execution order rather than its own fixture.

One retained correction restored the clock at the end of the test. A later boundary test used try and finally, which protects cleanup when the body fails. That later shape is the safer containment pattern.

Containment is still not an application boundary. Production code continues to read ambient time, callers still cannot see the dependency, and calculations can still observe different instants within one operation.

Use a global freeze when it is the smallest honest way to characterize legacy behavior. Do not mistake a deterministic test for an explicit design.

Most decisions need an instant, not a clock

If a calculation asks one temporal question, pass the reference instant:

final class Promotion
{
    public function appliesAt(DateTimeImmutable $at): bool
    {
        return $at < $this->endsAt;
    }
}

The caller owns when “now” is read:

$observedAt = $clock->now();

$promotion->appliesAt($observedAt);
$price->calculateAt($observedAt);
$eligibility->checkAt($observedAt);

All three decisions now describe one business observation. A retry can preserve the original instant when that is the intended policy, or supply a new one when reevaluation is the intended policy. The difference is visible.

Passing a clock into every value object would be worse. It gives simple calculations an unnecessary collaborator and lets them read time repeatedly. Prefer a value when the operation needs one observation; prefer a clock at the application boundary which decides when that observation is made.

Read once when the operation means once

Two ambient reads can disagree at exactly the interesting boundary:

23:59:59.999  eligibility check says today
00:00:00.001  ledger key uses tomorrow

This is not primarily a timezone problem. Both reads may use the same timezone and still straddle midnight. The problem is that one operation acquired two reference instants.

Capture time once when several effects should describe the same decision:

observed_at = clock.now()

eligibility = policy.checkAt(observed_at)
period      = ledger.periodAt(observed_at)
audit       = DecisionRecorded(observed_at)

Read again only when elapsed time is part of the contract. A polling loop, timeout, or benchmark genuinely needs a moving clock. Even then, an injected clock makes that behavior controllable and tells the reader why repeated reads exist.

Boundary tests should name the boundary

Freezing the retained shipment test to the fifteenth made “two days ago” stay inside the current month. That protected the repository count from the calendar while leaving the actual month edge unexamined.

A stronger suite separates the questions:

ordinary example
    observed at the fifteenth
    shipment two days earlier
    expected inside the month

first-day boundary
    observed just after the month begins
    shipment two days earlier
    expected outside the month

end boundary
    shipment exactly at the exclusive next-month instant
    expected outside the month

The ordinary example no longer changes with the date of execution. The boundary examples state which side of the cutoff they expect.

A separate retained pruning test did this more carefully. It froze a fixed instant, created successful records on either side of a thirty-day policy, ran the pruning operation, and restored the clock in finally. That test owns the time boundary instead of borrowing today’s date.

Enforce explicit time where it changes a decision

A blanket ban on now() is noisy. Audit timestamps, request logging, and presentation defaults may legitimately read the system clock at their boundary.

The retained application instead introduced a configured static-analysis rule for named pricing, cutoff, surcharge, and eligibility methods. It rejects the framework time helper and direct mutable or immutable clock reads inside those methods. Its diagnostic asks for an explicit clock or reference time.

The initial inventory contained fifteen deferred findings across seven files. That is an enforcement foothold, not a completed refactor. The rule deliberately does not inspect every method. An unconfigured calculation can still read ambient time, and a configured method can hide a read behind another call.

A useful rollout is:

  1. identify calculations whose result changes with time;
  2. list their current violations without pretending they are fixed;
  3. pass a reference instant through one boundary at a time;
  4. remove each exception when the code changes; and
  5. expand enforcement only where the distinction remains useful.

The target is not syntactic purity. It is to make decision inputs reviewable.

A clock interface is deliberately small

PSR-20 standardizes a clock with one operation returning an immutable date-time value. Its stated purpose includes testing behavior with temporal side effects when direct system-clock reads cannot be controlled.1

That interface is enough at an application boundary:

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

    public function execute(Promotion $promotion): void
    {
        $promotion->activateAt($this->clock->now());
    }
}

The domain operation receives the value which changes its decision. The application service receives the capability which obtains that value.

A closure or project-specific interface can serve the same role. Adopting a standard interface is useful when libraries and framework adapters should interoperate; it is not required to make the dependency explicit.

The fixture exposes the leak

The public reproduction models a mutable process clock and a monthly calculation. It proves four properties with synthetic dates:

  • the same parameterless calculation changes after the global clock moves;
  • a frozen clock left unrestored changes a later test;
  • finally restores the process clock after a thrown error; and
  • an explicit reference instant produces the same result regardless of global state.

It does not run the private framework, repository, test suite, or analyzer. It does not prove that the retained application has completed its explicit-clock migration.

Put the clock where the decision begins

Freezing time is the right repair when a legacy test accidentally depends on the day it runs. Guaranteed cleanup keeps that repair from contaminating the suite.

The design question comes next: which operation decides what “now” means?

Put a clock at that boundary, read it once for one business observation, and pass the resulting instant to the calculations which need it. Then a local call can explain its result without consulting the wall clock, the test order, or a process-wide setting invisible to its caller.


  1. PHP-FIG, “PSR-20: Clock” , accessed 2026-07-25. ↩︎