【PHP】CarbonのaddMonth()を使ってハマった話(CarbonとDateTimeクラスの仕様を今一度確認してみる)


TL;DR

特に理由がなければCarbonを用いて月の計算をする場合はaddMonthsNoOverflow()メソッドを使うようにしたほうがいい

DateTimeクラスの仕様に度々ひっかかる

Carbonを用いて月の計算を行っていると、どうやら28~31日のデータを扱うときに挙動がおかしいことに気が付きました
前もこんなのあったなーと思いつつ再現させる
(前:【PHP】ある月の初日と末日を取得する方法(DateTimeクラス & Carbon編)#要注意引数は年月日を渡しましょう - Qiita

検証

例:契約日が15日未満の場合は翌月の27日、15日以降なら翌々月の27日が請求月になる
みたいな場合の処理
(ここでは契約日が2018-01-31で考えます)
環境としてはLaravelのv5.5系に入っているCarbon(1.36)を使用して以下の例の処理を動かします

use Carbon\Carbon;

$array = [];
$contractDate = new Carbon('2018-01-31');

// 5回払い
for ($i = 0; $i < 5; $i++) {
    // 契約日が15日以降か
    if ((int)$contractDate->format('d') < 15) {
        $array['date'] = (new Carbon($contractDate))->addMonth($i + 1)->format("Y-m-27");
    } else {
        $array['date'] = (new Carbon($contractDate))->addMonth($i + 2)->format("Y-m-27");
    }

    $array['target_month'] = (new Carbon($array['date']))->format('Y-m');

    var_dump($i);
    var_dump($array);
}

↓以下、tinker(laravel用REPL)で実行した結果

int(0)
array(2) {
  ["date"]=>
  string(10) "2018-03-27"
  ["target_month"]=>
  string(7) "2018-03"
}
int(1)
array(2) {
  ["date"]=>
  string(10) "2018-05-27"
  ["target_month"]=>
  string(7) "2018-05"
}
int(2)
array(2) {
  ["date"]=>
  string(10) "2018-05-27"
  ["target_month"]=>
  string(7) "2018-05"
}
int(3)
array(2) {
  ["date"]=>
  string(10) "2018-07-27"
  ["target_month"]=>
  string(7) "2018-07"
}
int(4)
array(2) {
  ["date"]=>
  string(10) "2018-07-27"
  ["target_month"]=>
  string(7) "2018-07"
}
>>>

なんだこれは・・・

CarbonのaddMonth()について調べてみる

CarbonDateTimeクラスのラッパーなので、基本的にDateTimeクラスの仕様に依存してるみたいです。
んでaddMonth()の処理がどうなっているかCarbonのソースコード(Carbon/Carbon.php at version-1.36)を見てみると

Carbon/Carbon.php
    /**
     * Add a month to the instance
     *
     * @param int $value
     *
     * @return static
     */
    public function addMonth($value = 1)
    {
        return $this->addMonths($value);
    }

addMonths()メソッドを呼び、

Carbon/Carbon.php
    /**
     * Add months to the instance. Positive $value travels forward while
     * negative $value travels into the past.
     *
     * @param int $value
     *
     * @return static
     */
    public function addMonths($value)
    {
        if (static::shouldOverflowMonths()) {
            return $this->addMonthsWithOverflow($value);
        }
        return $this->addMonthsNoOverflow($value);
    }

static::shouldOverflowMonths()で呼ばれているmonthsOverflowプロパティはデフォルトでtrueになっているので、
オーバーフローで月を計算するメソッドaddMonthsWithOverflow()を呼びます。

Carbon/Carbon.php
    /**
     * Add months to the instance. Positive $value travels forward while
     * negative $value travels into the past.
     *
     * @param int $value
     *
     * @return static
     */
    public function addMonthsWithOverflow($value)
    {
        return $this->modify((int) $value.' month');
    }

でその中身はDateTimeクラスのmodify()メソッドでした。
ドキュメント(PHP: DateTime::modify - Manual)にも書いてある通り、月の加減算には注意ということで以下の処理例が載ってます。

例2 月の加減算には注意

<?php
$date = new DateTime('2000-12-31');

$date->modify('+1 month');
echo $date->format('Y-m-d') . "\n";

$date->modify('+1 month');
echo $date->format('Y-m-d') . "\n";
?>

上の例の出力は以下となります。
2001-01-31
2001-03-03

要は月の計算(加算)時に存在しない日(2月が28日までで、31日がない)になった場合、あふれた日数(3日)次の月(3月)+して計算する(=3月3日)という仕様になってるんですね

確認するためにmodify()メソッドを使用して処理を書いてみました

date_default_timezone_set('Asia/Tokyo');

for ($i = 0; $i < 10; $i++) {
    $date = new DateTime('2018-01-31');
    $date->modify((int) ($i + 2).' month');
    var_dump($date->format('Y-m-d H:i') ." → " . $date->format('Y-m-27'));
    echo PHP_EOL;
}

以下実行結果

string(35) "2018-03-31 00:00 → 2018-03-27"
string(35) "2018-05-01 00:00 → 2018-05-27"
string(35) "2018-05-31 00:00 → 2018-05-27"
string(35) "2018-07-01 00:00 → 2018-07-27"
string(35) "2018-07-31 00:00 → 2018-07-27"
string(35) "2018-08-31 00:00 → 2018-08-27"
string(35) "2018-10-01 00:00 → 2018-10-27"
string(35) "2018-10-31 00:00 → 2018-10-27"
string(35) "2018-12-01 00:00 → 2018-12-27"
string(35) "2018-12-31 00:00 → 2018-12-27"

https://3v4l.org/WfDnT

案の定、最初に書いた処理と同じような結果になりました。
(※4月や6月は30日までしかないので、1日分、次の月に+されてしまってます)

オーバーフローしないメソッドaddMonthsNoOverflow()

ならオーバーフローしない計算メソッドを使えばいいんではないか、ということで
CarbonのaddMonthsNoOverflow()メソッドの処理を見てみます。

Carbon/Carbon.php
    /**
     * Add months without overflowing to the instance. Positive $value
     * travels forward while negative $value travels into the past.
     *
     * @param int $value
     *
     * @return static
     */
    public function addMonthsNoOverflow($value)
    {
        $day = $this->day;
        $this->modify((int) $value.' month');
        if ($day !== $this->day) {
            $this->modify('last day of previous month');
        }
        return $this;
    }

結局はmodify()メソッドを使用してるみたいですが、'last day of previous month'(前の月の最後の日)と書かれてるので、オーバーフローした際は前月の最終日を返すようになっているようです。

参考:PHP: 相対的な書式 - Manual

正しい処理になるように修正

ということで、CarbonのaddMonthsNoOverflow()メソッドを用いて計算するように先ほどの処理を修正してみます。

use Carbon\Carbon;

$array = [];
$contractDate = new Carbon('2018-01-31');

// 5回払い
for ($i = 0; $i < 5; $i++) {

    if ((int)$contractDate->format('d') < 15) {
        // $array['date'] = (new Carbon($contractDate))->addMonth($i + 1)->format("Y-m-27");
        $array['date'] = (new Carbon($contractDate))->addMonthsNoOverflow($i + 1)->format("Y-m-27");
    } else {
        // $array['date'] = (new Carbon($contractDate))->addMonth($i + 2)->format("Y-m-27");
        $array['date'] = (new Carbon($contractDate))->addMonthsNoOverflow($i + 2)->format("Y-m-27");
    }

    $array['target_month'] = (new Carbon($array['date']))->format('Y-m');

    var_dump($i);
    var_dump($array);
}

以下、tinker(laravel用REPL)で実行した結果

int(0)
array(2) {
  ["date"]=>
  string(10) "2018-03-27"
  ["target_month"]=>
  string(7) "2018-03"
}
int(1)
array(2) {
  ["date"]=>
  string(10) "2018-04-27"
  ["target_month"]=>
  string(7) "2018-04"
}
int(2)
array(2) {
  ["date"]=>
  string(10) "2018-05-27"
  ["target_month"]=>
  string(7) "2018-05"
}
int(3)
array(2) {
  ["date"]=>
  string(10) "2018-06-27"
  ["target_month"]=>
  string(7) "2018-06"
}
int(4)
array(2) {
  ["date"]=>
  string(10) "2018-07-27"
  ["target_month"]=>
  string(7) "2018-07"
}
>>>

期待通りの処理になりました。

ということで特に理由がなければCarbonを用いて月の計算をする場合はaddMonthsNoOverflow()メソッドを使うようにしたほうがいいかなというお話でした。

おわり

  • DateTimeクラスの仕様には度々振り回されます・・・
  • というかドキュメントちゃんと読みましょう>自分

参考URL