Making system time an explicit dependency in Symfony
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.
Using named domain exceptions to express rejected business rules, roll back commands, cross Symfony Messenger safely, produce RFC 9457 problem responses, and separate expected failures from real incidents.
How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.
A practical testing architecture for combining fast domain tests, executable Behat application specifications, in-memory ports, deterministic time, real Symfony boundaries, and focused infrastructure tests.
Read
Need a software engineer?
Send the goal, stack, and timeline. I'll reply with a next step.
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.
This guide shows why direct system time is risky and replaces it with an explicit clock dependency in Symfony.
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.
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.
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:
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 $startedAt, $completedAt, or $failedAt according to their meaning. Do not scatter anonymous now() calls.
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.
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.
This query is convenient but introduces another clock:
SQL
WHERE valid_from <=CURRENT_TIMESTAMPAND(valid_until ISNULLOR 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.
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."
Application tests expose the controlled clock through business language:
PHP
finalreadonlyclassTimeContextimplementsContext{publicfunction__construct(privateTestClock$clock,){}#[Given('current time is :time')]publicfunctioncurrentTimeIs(string$time):void{$this->clock->setNow(newDateTimeImmutable($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.
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.
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
privatefunctionpreviousMonth(DateTimeImmutable$now):DateRange{$firstDayOfThisMonth=$now->setTimezone(newDateTimeZone('Africa/Lubumbashi'),)->modify('first day of this month')->setTime(0,0);returnnewDateRange(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. For the half-open convention start <= instant < end, keep firstDayOfThisMonth as the exclusive end. Encode the chosen convention once and test its 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.
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.
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 tests become faster because they no longer sleep, but control is the larger benefit. A rule can run at an expiry boundary or during a replay, and the code records which instant it used. Time-sensitive behavior stops depending on whichever clock happened to be read.