Edited at

【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