先月末、見事にやらかしたので備忘録的に PHP での適切な年月日の加算、減算を紹介します。
<?php
/**
* date 関数 と strtotime 関数
* インスタンスを使ってない分、処理が速そう(実測してないので本当に速いかは不明)
* 一行で書くのも簡単なのでテストデータの生成なんかでよく使っています。
* @see https://php.net/manual/ja/function.date.php
* @see https://php.net/manual/ja/function.strtotime.php
* @see https://www.php.net/manual/ja/datetime.formats.php
*/
echo "--バグパターン--\n\n";
// 2021-04-30 が出力されることを期待
echo date('Y-m-d', strtotime('-1 month', strtotime(date('2021-05-31'))));
// 2021-05-01 が出力
echo "\n\n--期待通りの動作パターン--\n\n";
echo date('Y-m-d', strtotime('last day of last month', strtotime(date('2021-05-31'))));
// 2021-04-30 が出力
/**
* DateTimeImmutable
* 素の PHP で日時ライブラリもかくやという程、色々ができます。
* @see https://www.php.net/manual/ja/class.datetimeimmutable.php
*/
echo "\n\n--バグパターン--\n\n";
// 2021-04-30 が出力されることを期待
echo DateTimeImmutable::createFromFormat('Y-m-d', '2021-05-31')
->modify('-1 month')
->format('Y-m-d');
// 2021-05-01 が出力
echo "\n\n--期待通りの動作パターン--\n\n";
echo DateTimeImmutable::createFromFormat('Y-m-d', '2021-05-31')
->modify('last day of last month')
->format('Y-m-d');
// 2021-04-30 が出力
/**
* Carbon
* PHP の日時操作ライブラリで PHP 組み込みクラスである DateTime クラスの拡張です。
* ライブラリを使うことを考える状況で日時を取り扱う時はよく使います。
* @see https://carbon.nesbot.com/
* @see https://www.php.net/manual/ja/class.datetime.php/
*/
require 'vendor/autoload.php';
echo "\n\n--バグパターン--\n\n";
// 2021-04-30 が出力されることを期待
echo \Carbon\Carbon::createFromFormat('Y-m-d', '2021-05-31')
->subMonth()
->format('Y-m-d');
// 2021-05-01 が出力
echo "\n\n--期待通りの動作パターン--\n\n";
echo \Carbon\Carbon::createFromFormat('Y-m-d', '2021-05-31')
->subMonthsWithoutOverflow()
->format('Y-m-d');
// 2021-04-30 が出力
echo "\n\n--バグパターン--\n\n";
echo \Carbon\Carbon::createFromFormat('Y-m-d', '2021-05-31')
->subMonths(3)// 2021-02-28 を期待
->format('Y-m-d');
// 2021-03-03 が出力
echo "\n\n--期待通りの動作パターン--\n\n";
echo \Carbon\Carbon::createFromFormat('Y-m-d', '2021-05-31')
->subMonthsWithoutOverflow(3)// 2021-02-28 を期待
->format('Y-m-d');
// 2021-02-28 が出力
1 month が今参照している月の日数の代数的な定義のため、この様な挙動となります。
こういった処理は月の日数が変わる月の月末(例. 3月→4月で31日→30日、4月→5月で30日→31日)に起こりやすいです(年も閏年関連で同様ですね)。この手の問題を手計算せずに済む仕組みは大抵用意されていますがそれが適切に使われているかはコードや挙動を調べる必要があります。日時が何かしらの境界となる処理においては Carbon::setTestNow メソッドの使用や動作させるマシン自体の時計を弄るなどして特定の日時でソースコードを走らせて問題がないかテストしておくべきです。