Carbonで日付処理のバグを防ぐ:タイムゾーンと不変性の落とし穴

概要

PHP/Laravel で日付を扱うなら Carbon はほぼ必須のライブラリですが、「タイムゾーン」と「ミュータブル(可変)性」の 2 つを理解していないと、テストでは通るのに本番で日付がずれる、集計が 1 件だけ狂う、といった再現しづらいバグを踏みます。

この記事では、実際に遭遇しがちな 2 系統のトラブル(タイムゾーンずれ/Carbon インスタンスの破壊的変更)について、原因と対策、確認方法をまとめます。

環境

  • PHP 8.x
  • Laravel 10 / 11 系
  • nesbot/carbon v2 系(CarbonImmutable を含む)
  • 想定: アプリの表示は日本時間(JST, Asia/Tokyo)、DB は UTC 保存

発生した問題

症状1: 「今日の件数」がずれる

「本日登録されたレコード数」を集計すると、深夜〜早朝の時間帯だけ件数が 1 日ずれる。日中は正しい。

症状2: 期間フィルタが1日分足りない/多い

「今月分」を startOfMonth / endOfMonth で絞ったはずが、月初・月末のレコードが取りこぼされる、または翌月分が混ざる。

症状3: 同じ日付を使い回すと値が変わる

基準日から「7日後」「14日後」を計算したら、なぜか両方とも 21 日後になっていた。

原因

原因1: アプリのタイムゾーンとDB保存値の不一致

Laravel の config/app.php の timezone を Asia/Tokyo に設定していても、Eloquent の created_at などは基本的に UTC を前提に保存・取得されます。「now() が返すのは何時なのか」「DB のカラムに入っている値は UTC なのか JST なのか」を混同すると、日境界(0:00)付近でずれます。

JST の 2026-09-14 08:00 は UTC では 2026-09-13 23:00。UTC 基準で「9/14 の 0:00〜」を境界にすると、JST の朝方に作られたレコードが「前日」に入り込みます。

原因2: Carbon はデフォルトでミュータブル(破壊的)

最大の落とし穴がこれです。Carbon インスタンスの addDays() などは 元のインスタンス自身を書き換えて 返します。

$base = Carbon::parse('2026-09-14');

$sevenDaysLater    = $base->addDays(7);   // $base 自身が 9/21 になる
$fourteenDaysLater = $base->addDays(7);   // さらに +7 で 9/28、しかも $base も 9/28

// 2つの変数は同じインスタンスを指している

$base を「不変の基準日」だと思って使い回すと、参照先が次々に書き換わり、症状3 のような「両方同じ値」「意図しない加算の蓄積」が起きます。

対応方法

対策1: タイムゾーンは「入口で変換・境界を明示」

「境界を作る側」と「DB の値」のタイムゾーンを必ず一致させます。曖昧な now() をそのまま比較に使わないのが要点です。

// JSTで「今日の範囲」を作り、UTCに変換してからクエリへ
$tz = 'Asia/Tokyo';
$startJst = Carbon::now($tz)->startOfDay();          // JST 0:00
$endJst   = Carbon::now($tz)->endOfDay();            // JST 23:59:59

$count = Order::whereBetween('created_at', [
    $startJst->clone()->setTimezone('UTC'),
    $endJst->clone()->setTimezone('UTC'),
])->count();

対策2: 不変が欲しいなら CarbonImmutable を使う

CarbonImmutable を使えば、破壊的変更に起因するバグは構造的に防げます。addDays() などは常に 新しいインスタンス を返し、元は変わりません。

use Carbon\CarbonImmutable;

$base = CarbonImmutable::parse('2026-09-14');

$sevenDaysLater    = $base->addDays(7);  // 9/21($base は 9/14 のまま)
$fourteenDaysLater = $base->addDays(14); // 9/28($base は 9/14 のまま)

Laravel なら、AppServiceProvider の boot() でアプリ全体を Immutable 前提に切り替えることもできます。

use Illuminate\Support\Carbon;

public function boot(): void
{
    Carbon::useImmutable();
}

対策3: ミュータブルのまま使うなら clone を徹底

既存コードが可変前提で全面移行が難しい場合は、基準日から派生値を作るたびに clone() します。

$base = Carbon::parse('2026-09-14');

$sevenDaysLater    = $base->clone()->addDays(7);
$fourteenDaysLater = $base->clone()->addDays(14);
// $base は 9/14 のまま

実装例

「基準日から複数の派生日を計算する」ユーティリティを、安全な形で書くとこうなります。

use Carbon\CarbonImmutable;

class BillingPeriod
{
    public function __construct(
        private readonly CarbonImmutable $base,
    ) {}

    /** 請求サイクルの区切りを返す(baseは一切変わらない) */
    public function milestones(): array
    {
        return [
            'issued_at'   => $this->base,
            'due_at'      => $this->base->addDays(30),
            'reminder_at' => $this->base->addDays(23),
            'overdue_at'  => $this->base->addDays(31)->startOfDay(),
        ];
    }
}

$period = new BillingPeriod(CarbonImmutable::parse('2026-09-14', 'Asia/Tokyo'));
$m = $period->milestones();
// $m['due_at'] と $m['reminder_at'] は独立した別インスタンス

引数で受け取った可変インスタンスを関数内で書き換えると呼び出し側の値が変わるため、「引数で受けた Carbon を書き換えない」というコーディング規約にしておくのが安全です。

確認方法

再現テストを書く(不変性)

public function test_base_date_is_not_mutated(): void
{
    $base = CarbonImmutable::parse('2026-09-14');

    $base->addDays(7);  // 戻り値を捨てても…

    $this->assertSame('2026-09-14', $base->toDateString()); // baseは不変
}

Carbon(可変)で同じテストを書くとこの assert は落ちます。これが「可変であること」の証明になります。

タイムゾーン境界のテスト

Carbon::setTestNow() で時刻を固定し、日境界付近を検証します。

public function test_today_count_respects_jst_boundary(): void
{
    // JST 9/14 00:30 = UTC 9/13 15:30
    Carbon::setTestNow(Carbon::parse('2026-09-14 00:30:00', 'Asia/Tokyo'));

    $start = Carbon::now('Asia/Tokyo')->startOfDay();
    $this->assertSame('2026-09-14 00:00:00', $start->format('Y-m-d H:i:s'));

    Carbon::setTestNow(); // 後始末を忘れずに
}

注意点

  • useImmutable() を後から全体適用すると、破壊的変更を前提に書かれた箇所の挙動が変わります。切り替えはテストがある状態で段階的に。
  • diffInDays() などの差分メソッドは、絶対値・符号・タイムゾーンの扱いに癖があります。境界日の差分は必ずテストで固定して確認します。
  • parse() にタイムゾーンを渡さないと、アプリのデフォルトタイムゾーンが暗黙で使われます。文字列パース時はタイムゾーンを明示するのが安全です。
  • setTestNow() はテスト後に必ず解除する(tearDown で Carbon::setTestNow())。解除漏れは他テストを巻き込みます。

まとめ

  • Carbon のバグの多くは「タイムゾーンの不一致」と「デフォルトが可変であること」に集約される。
  • 境界計算では DB の保存基準と境界の基準を一致 させ、setTimezone を明示する。
  • 不変性が欲しいなら CarbonImmutable、既存が可変なら 派生ごとに clone()。
  • 不変性・境界ずれは setTestNow() を使ったユニットテストで確実に固定・検出できる。「テストで再現できる」状態にしておくのが、この手のバグ再発防止のいちばんの近道です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。