LoginSignup
41
28

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-04-15

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

41
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
41
28