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()
について調べてみる
CarbonはDateTimeクラスのラッパーなので、基本的にDateTimeクラスの仕様に依存してるみたいです。
んでaddMonth()
の処理がどうなっているかCarbonのソースコード(Carbon/Carbon.php at version-1.36)を見てみると
/**
* Add a month to the instance
*
* @param int $value
*
* @return static
*/
public function addMonth($value = 1)
{
return $this->addMonths($value);
}
addMonths()
メソッドを呼び、
/**
* 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()
**を呼びます。
/**
* 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"
案の定、最初に書いた処理と同じような結果になりました。
(※4月や6月は30日までしかないので、1日分、次の月に+されてしまってます)
オーバーフローしないメソッドaddMonthsNoOverflow()
ならオーバーフローしない計算メソッドを使えばいいんではないか、ということで
CarbonのaddMonthsNoOverflow()
メソッドの処理を見てみます。
/**
* 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'
(前の月の最後の日)**と書かれてるので、オーバーフローした際は前月の最終日を返すようになっているようです。
正しい処理になるように修正
ということで、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クラスの仕様には度々振り回されます・・・
- というかドキュメントちゃんと読みましょう>自分