CarbonのaddMonth()を安易に使ってしくじった話

PHPのアプリケーションで日付処理を行う場合、日付処理ライブラリとして「Carbon」を使う機会が多いと思います。私もCarbonをよく利用しているのですが、あるアプリケーション開発でCarbonのaddMonth()を使った際に、しくじったのでそれについて書きます。

※完全にしくじり話で、分かる方にとっては当たり前の話かと思います。

アプリケーションの概要と処理

具体的には書けませんが、アプリケーションの概要は、何かしらの契約の更新期限日のxヶ月前に、通知を行うというようなものです。

毎日通知コマンドを実行しており、コマンドを実行する日付に、CarbonのaddMonth()で xヶ月後(例:1ヶ月後)を加算し、加算後の日付(更新期限日)を持つレコードを抽出して、それに対し通知処理を実行するということをしていました。

こんなイメージです。

$dt = Carbon::now()->addMonth();

$users = User::where('expiration_date', $dt)->get();

addMonth()の挙動により、重複して通知が実行されてしまった

ご存知の方も多いと思いますが、まずaddMonth()を使う際によくあるミスでしくじりました。
月は、31日まである月もあれば、28日までしかない月もあります。ドキュメントにも書いてあるのですが、CarbonのaddMonth()では、あふれた日付を次の月に加算します。

$dt1 =  Carbon::create(2019, 1, 31, 0);

echo $dt1->addMonth();                    // 2019-03-03 00:00:00

CarbonのaddMonth()では、DateTimeクラスのmodifyが使われており、以下のようなロジックになっています。
>月数を相対指定すると、その途中に経過する月の日数を使って結果を算出します。 たとえば "+2 month 2011-11-30" の結果は "2012-01-30" となります。 11 月の日数は 30 日、12 月の日数は 31 日なので、 その合計である 61 日後となるわけです。
PHP: 相対的な書式 - Manual
https://www.php.net/manual/ja/datetime.formats.relative.php

この挙動ですと、今回のアプリケーションでは、**「重複して通知処理を行ってしまう」**という不具合が生じてしまいます。

今回のアプリケーション
>具体的には書けませんが、アプリケーションの概要は、何かしらの契約の更新期限日のxヶ月前に、通知を行うというようなものです。

>毎日通知コマンドを実行しており、コマンドを実行する日付に、CarbonのaddMonth()で xヶ月後(例:1ヶ月後)を加算し、加算後の日付(更新期限日)を持つレコードを抽出して、それに対し通知処理を実行するということをしていました。

上記のような使い方をした場合、1/31の1ヶ月後も、2/3の1ヶ月後も3/3を指してしまい、3/3の更新期限日を持つレコードに対して、重複処理が実行されてしまいます。

$dt1 =  Carbon::create(2019, 1, 31, 0);
$dt2 =  Carbon::create(2019, 2, 3, 0);

echo $dt1->addMonth();                    // 2019-03-03 00:00:00
echo $dt2->addMonth();                    // 2019-03-03 00:00:00

まず見事にこれでしくじりました。

addMonthNoOverflow()でも同じような問題が起きる

もちろん上記の挙動については、Carbonのドキュメントでも書いてあって、解決策も書いてありました。
それが、addMonthNoOverflow()を使うやり方です。つまりは月跨ぎを行わないようにすることですね。
これはこんな挙動になります。対象の日付がない場合は、最終日を指すようになるのですね。

$dt1 =  Carbon::create(2019, 1, 31, 0);

echo $dt1->addMonthNoOverflow();                    // 2019-02-28 00:00:00


しかし、今回のアプリケーションの通知コマンドのような処理では、addMonth()と同じような問題が発生してしまいます。
1/28、1/29、1/30、1/31のそれぞれ1ヶ月後は、全て2/28を差してしまい、これでも重複処理が実行されてしまいます。

$dt1 =  Carbon::create(2019, 1, 28, 0);
$dt2 =  Carbon::create(2019, 1, 29, 0);
$dt3 =  Carbon::create(2019, 1, 30, 0);
$dt4 =  Carbon::create(2019, 1, 31, 0);

echo $dt1->addMonthNoOverflow();                    // 2019-02-28 00:00:00
echo $dt2->addMonthNoOverflow();                    // 2019-02-28 00:00:00
echo $dt3->addMonthNoOverflow();                    // 2019-02-28 00:00:00
echo $dt4->addMonthNoOverflow();                    // 2019-02-28 00:00:00

もっと致命的な問題も(もはやCarbonは無関係)

そもそもですが、今回のアプリケーションの通知コマンドのような処理では、CarbonのaddMonth()を使ってしまうと、「通知対象なのに、通知がされない」という致命的な問題も発生します。

例えば、更新期限日が2020/12/31のレコードがあり、更新期限日の1ヶ月前に通知をするという場合、CarbonのaddMonth()を使えば、2020/11/31に通知処理が実行されると思いますよね。

しかしながら、2020/11は30日までしかありません。つまり、addMonth()を使ってしまうと、2020/12/31のレコードを抽出してこれないため、通知処理も実行されません。

私の不徳の致すところであります。

結論:addDays()を使うことにした

addMonth()ではなく、addDays()を使い、30日を設定するようにしました。

これなら「重複して通知処理を行ってしまう」「通知対象なのに、通知がされない」という問題も発生しません。

今回のアプリケーションの要件では、xヶ月前に正しく通知がされればよいだけであり、その他に考慮することもなかったため、こちらで解決になりました。

$dt1 =  Carbon::create(2019, 1, 28, 0);
$dt2 =  Carbon::create(2019, 1, 29, 0);
$dt3 =  Carbon::create(2019, 1, 30, 0);
$dt4 =  Carbon::create(2019, 1, 31, 0);

echo $dt1->addDays(30);                    // 2019-02-27 00:00:00
echo $dt2->addDays(30);                    // 2019-02-28 00:00:00
echo $dt3->addDays(30);                    // 2019-03-01 00:00:00
echo $dt4->addDays(30);                    // 2019-03-02 00:00:00