はじめに
「現在時刻はグローバル変数」という話があります。time() や引数なしの date() をビジネスロジックの中で呼ぶと、コードが壁時計に直結します。
// リポジトリのあちこちにいた典型
$stmt->execute([
$client->name,
date('Y-m-d H:i:s'), // created_at
date('Y-m-d H:i:s'), // updated_at
]);
これの何が困るかは有名です。時刻境界のテストが書けない。トークンの有効期限が「発行から14日」なら、14日後ちょうど・1秒前・1秒後の3ケースをテストしたいのに、本物の時計では「今」しかテストできません。月末・年末・うるう日のバグは、その日になるまで再現できません。
解決策も有名です。時計を注入する(いわゆる Clock パターン、PSR-20 と同形)。ここまでは何百回も書かれた話なので、この記事の主題はその先にします。
既に動いているコードベースに、時刻直読みが数十箇所ある。これをどう返済するか。
自己ホスト型の請求書管理 OSS NeNe Invoice(PHP 8.4)で実際にやった手順 — 独自リンタで検出し、baseline で新規混入だけを堰き止め、2回のスイープで返済し、「本番挙動は1ビットも変わっていない」を論証する — をまとめます。
道具: 最小の ClockInterface
土台のフレームワーク(NENE2)が提供する時計はこれだけです。
interface ClockInterface
{
/** Implementations MUST return a value in UTC. */
public function now(): \DateTimeImmutable;
}
メソッド1本。本番実装の UtcClock は new DateTimeImmutable('now', new DateTimeZone('UTC')) を返すだけ、テスト実装の FixedClock は固定値を返すだけです。PSR-20(Psr\Clock\ClockInterface)と同じシグネチャなので、載せ替えも相互運用も自明です。
ポイントは戻り値を DateTimeImmutable に固定していること。format() すれば date() の代わりに、getTimestamp() すれば time() の代わりになるので、置換先はこれ一つで足ります。ミュータブルな DateTime を返すと、受け取った側の modify() が発行元を壊す事故があり得ます。
ステップ1: 見つける — grep では足りない
まず現状把握です。grep -rn "time()" は誤検知の山になります。コメント内の time()、$stopwatch->time() のようなメソッド呼び出し、date('Y-m-d', $knownTimestamp) のような既知の時刻の表示整形(これは時計読みではない)が全部引っかかるからです。
そこで検出はトークンベースの独自リンタルール(準拠リンタ D4)にしました。判定はこうです。
-
time()/microtime()/mktime()の裸のグローバル呼び出し → 違反(常に「今」を読む) -
date()/gmdate()は引数にタイムスタンプがない場合のみ違反(ある場合は整形であって時計読みではない) -
new DateTime()/new DateTimeImmutable('now')→ 違反 - コメント・docblock 内、メソッド呼び出し(
$x->time())、ClockInterface実装クラス自身 → 対象外
この「date() は引数の有無で区別する」が効きます。時刻の読み取りと表示整形は別の関心事で、狩るべきは前者だけです。ここを区別しないリンタは誤検知でオオカミ少年になり、すぐ無効化されます。
計測すると、内訳はきれいに3グループに分かれました。
-
認証クラスタ — トークンの発行・失効判定。生の
time()4件を含む。時刻がロジックに効く -
永続スタンプ —
Pdo*Repositoryのcreated_at/updated_at用date('Y-m-d H:i:s')が 36件・18ファイル。件数最多だが機械的 - JST 表示ヘルパ — 静的メソッドの中の「今」が 2件。少ないが一番厄介(後述)
ステップ2: 堰き止める — baseline 方式
全部を一晩で直すのは無理ですし、直すまでリンタを黙らせておくと新規混入が続きます。そこで baseline(grandfathering)方式にしました。リスク最大の認証クラスタだけは計測の直後に返済したので(次節の第1弾)、baseline に載ったのは残る 38件(スタンプ36 + ヘルパ2)です。
$ php tools/conformance.php --write-baseline
# → conformance.baseline.json に既存の38件が記録される
- baseline に載っている既存違反 → ignore(返済待ちの台帳)
- baseline にない新しい違反 → error(CI で落ちる)
リンタは composer check(CI ゲート)に組み込んだので、この時点で**「時刻直読みはこれ以上増えない」が保証**されました。返済のペースは自由に決められます。phpstan の baseline と同じ発想ですが、独自ルールでも台帳が JSON 1枚なら同じ運用が成立します。
ステップ3: 返済する — リスク順に2回に分けた
一括置換はしませんでした。グループごとにリスクが違ったからです。
第1弾: 認証クラスタ。 time() + TTL で有効期限を計算している、時刻がロジックに効く箇所。ここはテストの配当が最大なので先にやりました。
// Before
$now = time();
$expiresAtTs = $now + self::TOKEN_TTL_SECONDS; // 14日
// After
public function __construct(
private RefreshTokenRepositoryInterface $repository,
private ClockInterface $clock,
) {}
$now = $this->clock->now()->getTimestamp();
$expiresAtTs = $now + self::TOKEN_TTL_SECONDS;
置換した瞬間から、こういうテストが書けるようになります。
$clock = new FixedClock(new DateTimeImmutable('2026-07-01T00:00:00Z'));
$issuer = new RefreshTokenIssuer($repository, $clock);
$token = $issuer->issue($userId, $orgId);
// 「発行から14日」が 1 秒の誤差もなく検証できる
self::assertSame(
(new DateTimeImmutable('2026-07-15T00:00:00Z'))->getTimestamp(),
$token->expiresAtTimestamp,
);
以前は self::assertGreaterThan(time(), ...) のような幅を持たせた検証しかできませんでした。境界の完全一致が主張できるのは固定時計だけです。
第2弾: 永続スタンプ(36件・18ファイル)。 リポジトリの created_at / updated_at 用 date('Y-m-d H:i:s')。件数は多いが機械的で、各リポジトリのコンストラクタに ClockInterface を足し、$this->clock->now()->format('Y-m-d H:i:s') に置き換え、DI の配線とテスト25ファイルへの FixedClock 注入を追随させました。
残り2件は意図的に残しています(後述)。
ステップ4: 証明する — 「挙動不変」の論証
リファクタで一番大事なのは「何も変わっていない」の言い方です。今回は3層で担保しました。
-
同値性の論証。 アプリは bootstrap でプロセス TZ を UTC に固定しています。その前提下では
UtcClock->now()->format('Y-m-d H:i:s')はdate('Y-m-d H:i:s')と同一文字列、now()->getTimestamp()はtime()と同値です。つまり置換は恒等変換で、変わるのはテストから時刻を差し替えられるようになったことだけ。 - 回帰テスト。 902 テスト / 3,158 アサーションが全緑。
-
実機 E2E。 実際にログイン(refresh token の書き込み)→取引先・品目作成→請求書作成→発行まで通し、採番と
issued_atが実時刻と一致することを確認。
置換自体は機械的でも、この3点セットを添えるかどうかでレビューのしやすさがまったく違います。
正直な話: 最後まで残るのは「静的ヘルパの中の時刻」
baseline は 38件 → 2件になりました。残ったのは JST 変換ヘルパの中の「今」を読む2メソッドです。
final class Jst
{
/** 一覧フィルタの既定値や期限超過チェックに使う、JST の今日 (`Y-m-d`)。 */
public static function today(): string
{
return self::of(new DateTimeImmutable('now', self::utc()))->format('Y-m-d');
}
/** JST の現在時刻 (`Y-m-d H:i:s`)。 */
public static function nowString(): string { /* 同様 */ }
}
補足すると、この Jst クラス自体は大部分が「既知の時刻を UTC↔JST 変換する」メソッドで、それらはリンタの対象外です(時計を読んでいないので)。引っかかるのは「今」を読む上の2つだけ。
これが厄介なのは、static メソッドには何も注入できないからです。直すには「静的ヘルパをやめてインスタンス化し、20箇所以上の呼び出し元へ clock を貫通させる」必要があり、しかも呼び出し元は請求書の期限超過判定に隣接しています。機械的な置換の範囲を超えるので、別 issue に切って baseline に残しました(台帳があるから「残せる」わけです)。
教訓としてまとめると:
- コンストラクタ注入で時刻を読んでいるコードは、いつでも返済できる(今回の36件は1日で終わった)
-
静的ヘルパに時刻を埋めると、最後まで残る。新規コードでは
Foo::today()のような便利ヘルパに「今」を埋めないのが一番安い予防です
まとめ
| ステップ | やったこと | 効果 |
|---|---|---|
| 見つける | トークンベースの独自リンタ(整形用 date($fmt, $ts) は除外) |
誤検知のない実測と3グループの内訳 |
| 堰き止める | baseline 方式で CI ゲート(残存38件を台帳化) | 新規混入ゼロを即日保証 |
| 返済する | リスク順に2スイープ(認証 → スタンプ36件) | 時刻境界テストが決定論に |
| 証明する | 恒等変換の論証 + 全テスト + 実機 E2E | 「挙動不変」をレビュー可能に |
「Clock を注入しよう」で終わる記事は多いですが、実務の本題は既存コードへの後付けです。リンタで増加を止めてから、リスク順に返す — この順番なら、38件あっても怖くありませんでした。
NeNe Invoice は MIT ライセンスの OSS です。この記事のスイープは GitHub の #591(認証)と #598(永続スタンプ)、リンタルールはフレームワーク側 NENE2 の Conformance/Rule/RawClockRule.php にあります。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp