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

# TL;DR

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

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

**Carbonを用いて月の計算を行っていると**、どうやら**28～31日のデータを扱うときに挙動がおかしい**ことに気が付きました
前もこんなのあったなーと思いつつ再現させる
（前：[【PHP】ある月の初日と末日を取得する方法（DateTimeクラス & Carbon編）#要注意引数は年月日を渡しましょう - Qiita](https://qiita.com/sola-msr/items/58479913ff62452b6bb6#%E8%A6%81%E6%B3%A8%E6%84%8F%E5%BC%95%E6%95%B0%E3%81%AF%E5%B9%B4%E6%9C%88%E6%97%A5%E3%82%92%E6%B8%A1%E3%81%97%E3%81%BE%E3%81%97%E3%82%87%E3%81%86) ）

# 検証

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

```php
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](https://github.com/laravel/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()`について調べてみる

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

```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](https://www.php.net/manual/ja/datetime.modify.php)）にも書いてある通り、**月の加減算には注意**ということで以下の処理例が載ってます。

> 例2 月の加減算には注意
>
>```php
><?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()`メソッドを使用して処理を書いてみました

```php
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](https://www.php.net/manual/ja/datetime.formats.relative.php)

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

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

```php
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](https://github.com/laravel/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
- [php - Carbon - why addMonths() change the day of month? - Stack Overflow](https://stackoverflow.com/questions/39207798/carbon-why-addmonths-change-the-day-of-month)
- [[PHP]CarbonでaddMonthsは思った動きをしないのでaddMonthsNoOverflowを使おう！ | みるくぜりー](http://kayano.jugem.cc/?eid=689)
- [Carbon - A simple PHP API extension for DateTime.](https://carbon.nesbot.com/docs/)
- [PHP: DateTime::modify - Manual](https://www.php.net/manual/ja/datetime.modify.php)
- [PHP: 相対的な書式 - Manual](https://www.php.net/manual/ja/datetime.formats.relative.php)