PHPで1ヶ月前や1ヶ月後の日付を求めるには

PHP で1ヶ月前・1ヶ月後の日付を求めたとき、月末最終日で期待通りの結果にならないことがあります。

# 期待通りにならない場合
## 加算
**2019年1月31日** に1ヶ月加算します。
**2019年2月28日** が返ってくると思うかもしれませんが、実際には **2019年3月3日** が返ってきます。

```php
// date() を使った場合
date("Y-m-d H:i:s", strtotime("2019/1/31 10:00 + months"));
// --> 2019-03-03 10:00:00

// DateTimeImmutable クラス
// modify() を使った場合
$date = new DateTimeImmutable("2019/1/31 10:00");
$date->modify("+1 months")->format("Y-m-d H:i:s");
// --> 2019-03-03 10:00:00

// add() を使った場合
$date = new DateTimeImmutable("2019/1/31 10:00");
$date->add(new DateInterval("P1M"))->format("Y-m-d H:i:s");
// --> 2019-03-03 10:00:00
```

今年の2月は28日までなのですが、PHP は 31 - 28 = 3日分不足していると判断して、3月から最初の3日分を持ってくるという処理をしているらしいです。[^1]

このように「月末最終日の1ヶ月後」を **日数**（31日後）ではなく **翌月最終日** と考えていると、拍子抜けすることになります。

[^1]:  **2020年1月31日** に1ヶ月加算した場合は **2020年2月29日** が返ってきます。これは 31 - 29 = 2日分不足していると判断しているからでしょう。

## 減算
**2019年7月31日** を1ヶ月減算します。
**2019年6月30日** が返ってくると思うかもしれませんが、実際には **2019年7月1日** が返ってきます。

```php
// date() を使った場合
date("Y-m-d H:i:s", strtotime("2019/7/31 10:00 -1 months"));
// --> 2019-07-01 10:00:00

// DateTimeImmutable クラス
// modify() を使った場合
$date = new DateTimeImmutable("2019/7/31 10:00");
$date->modify("-1 months")->format("Y-m-d H:i:s");
// --> 2019-07-01 10:00:00

// sub() を使った場合
$date = new DateTimeImmutable("2019/7/31 10:00");
$date->sub(new DateInterval("P1M"))->format("Y-m-d H:i:s");
// --> 2019-07-01 10:00:00
```

ちなみに、**2019年6月30日** を1ヶ月減算した場合は **2019年5月30日** となります。

## ちなみに
かなり以前から知られている問題らしく、検索すると、さまざまな解決法が見つかります。

また、`DateTime::modify()` の [マニュアル](http://php.net/manual/ja/datetime.modify.php) には、**月の加減算には注意** として例示されていますが、以下のコメントは開発者の気持ちを代弁してくれているかと思います（笑）

> I cant believe this is in official PHPDOC, such an incredible retarded bug, and, best of all, No explanation at all... this is the kind of things that make PHPCore developers look like fools.
> 
> （意訳）これが PHP の公式ドキュメントだなんてマジでありえない。ひでえバグ作りやがって。マジで意味わかんねえ。PHP コアの開発者ってアホばっかなんじゃね？

# 自作関数
すでに多くの作成例がありますが、自分でも作ってみようと思いました。

PHP には `DateTime` クラスと `DateTimeImmutable` クラスがありますが、どちらも渡せるようにしています（それ以外が渡されたら TypeError になります）。

また、加減算する月数を第2引数で指定できるようにしています。やりたければ13ヶ月後とかも可能なはずです。

もし上手く動かない等ありましたら、ご指摘くださいませ。

## 加算
```php
/**
 * 指定された月数分加算する
 *
 * @param DateTimeInterface $before 加算前のDateTimeオブジェクト
 * @param int 月数（指定ない場合は1ヶ月）
 * @return DateTime DateTimeオブジェクト
 */
function addMonth(DateTimeInterface $before, int $month = 1) {
  $beforeMonth = $before->format("n");

  // 加算する
  $after       = $before->add(new DateInterval("P" . $month . "M"));
  $afterMonth  = $after->format("n");

  // 加算結果が期待値と異なる場合は、前月の最終日に修正する
  $tmpAfterMonth = $beforeMonth + $month;
  $expectAfterMonth = $tmpAfterMonth > 12 ? $tmpAfterMonth - 12 : $tmpAfterMonth;

  if ($expectAfterMonth != $afterMonth) {
    $after = $after->modify("last day of last month");
  }

  return $after;
```

以下のように使います。

```php
// 引数に DateTimeImmutable を指定した場合
$date = new DateTimeImmutable("2019/1/31 10:00");
echo "加算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "加算後: " .  addMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 引数に DateTime を指定した場合
$date = new DateTime("2019/1/31 10:00");
echo "加算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "加算後: " .  addMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 年を跨いでもOK
$date = new DateTimeImmutable("2018/12/31 10:00");
echo "加算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 2\n";
echo "加算後: " .  addMonth($date, 2)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 7月1日ではなく6月30日になる
$date = new DateTimeImmutable("2019/5/31 10:00");
echo "加算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "加算後: " . addMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 4年に1度の閏年もOK
$date = new DateTimeImmutable("2020/1/31 10:00");
echo "加算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "加算後: " . addMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";
```

処理結果は以下のとおりです。

```
加算前: 2019-01-31 10:00:00
月数  : 1
加算後: 2019-02-28 10:00:00

加算前: 2019-01-31 10:00:00
月数  : 1
加算後: 2019-02-28 10:00:00

加算前: 2018-12-31 10:00:00
月数  : 2
加算後: 2019-02-28 10:00:00

加算前: 2019-05-31 10:00:00
月数  : 1
加算後: 2019-06-30 10:00:00

加算前: 2020-01-31 10:00:00
月数  : 1
加算後: 2020-02-29 10:00:00
```

## 減算

```php
/**
 * 指定された月数分減算する
 *
 * @param DateTimeInterface $before 減算前のDateTimeオブジェクト
 * @param int 月数（指定ない場合は1ヶ月）
 * @return DateTime DateTimeオブジェクト
 */
function subMonth(DateTimeInterface $before, int $month = 1) {
  // 変更前の月を取得する
  $beforeMonth = $before->format("n");

  // 減算する
  $after       = $before->sub(new DateInterval("P" . $month . "M"));
  $afterMonth  = $after->format("n");

  // 減算結果が期待値と異なる場合は、前月の最終日に修正する
  $tmpAfterMonth = $beforeMonth - $month;
  $expectAfterMonth = $tmpAfterMonth <= 0 ? $tmpAfterMonth + 12 : $tmpAfterMonth;

  if ($expectAfterMonth != $afterMonth) {
    $after = $after->modify("last day of last month");
  }

  return $after;
}
```

以下のように使います。

```php
// 引数に DateTimeImmutable を指定した場合
$date = new DateTimeImmutable("2019/7/31 10:00");
echo "減算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "減算後: " .  subMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 引数に DateTime を指定した場合
$date = new DateTime("2019/7/31 10:00");
echo "減算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "減算後: " .  subMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 年を跨いでもOK
$date = new DateTimeImmutable("2019/2/28 10:00");
echo "減算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 2\n";
echo "減算後: " . subMonth($date, 2)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 3月2日ではなく2月28日になる
$date = new DateTimeImmutable("2019/4/30 10:00");
echo "減算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 2\n";
echo "減算後: " . subMonth($date, 2)->format("Y-m-d H:i:s"). "\n";
echo "\n";

// 4年に1度の閏年もOK
$date = new DateTimeImmutable("2020/3/31 10:00");
echo "減算前: " . $date->format("Y-m-d H:i:s") . "\n";
echo "月数  : 1\n";
echo "減算後: " . subMonth($date, 1)->format("Y-m-d H:i:s"). "\n";
echo "\n";
```

処理結果は以下のとおりです。

```
減算前: 2019-07-31 10:00:00
月数  : 1
減算後: 2019-06-30 10:00:00

減算前: 2019-07-31 10:00:00
月数  : 1
減算後: 2019-06-30 10:00:00

減算前: 2019-02-28 10:00:00
月数  : 2
減算後: 2018-12-28 10:00:00

減算前: 2019-04-30 10:00:00
月数  : 2
減算後: 2019-02-28 10:00:00

減算前: 2020-03-31 10:00:00
月数  : 1
減算後: 2020-02-29 10:00:00
```

# まとめ
PHP で月の加減算がこんなに面倒だとは思いませんでした・・・。