PHPのDateTimeで月末に1ヶ月加算すると日付がズレる理由と対処法
PHPのDateTimeで日付に「+1ヶ月」を加算すると、加算先の月に存在しない日付の分だけ翌月にあふれて、日付がズレることがあります。
月次の請求日やレポートの締め日をDateTimeで計算していて、気づいたら日付が変な位置にズレていた、みたいな経験ないでしょうか。私は月末日を基準にした定期処理でこれをやらかして、けっこう痛い目を見たことがあります。原因を追っていくと、これはバグというより「PHPの日付加算はそういう仕様になっている」という話で、知らないと何度でも踏み抜く類の落とし穴です。
なぜ月末に1ヶ月足すと日付がずれるのか
modify(‘+1 month’)という文字列指定でも、DateIntervalオブジェクトを作ってadd()するのでも、内部でやっていることは同じです。月の数値を1つ増やすという処理をしていて、1月31日に1ヶ月足すと、まず「2月31日」という存在しない日付が作られます。2月は31日まで無いので、はみ出した日数分だけ3月にずれ込むわけです。
declare(strict_types=1);
$d = new DateTimeImmutable('2026-01-31');
echo $d->modify('+1 month')->format('Y-m-d');
// 2026-03-03(2026年は平年なので2月は28日まで)
$d2 = new DateTimeImmutable('2026-03-31');
echo $d2->add(new DateInterval('P1M'))->format('Y-m-d');
// 2026-05-01(4月は30日までなので5月にあふれる)
modify()に文字列で「+1 month」と渡しても、DateIntervalを組み立ててadd()しても結果は同じです。うるう年かどうかでズレ幅も変わるので(2月が29日ある年は1日分ズレが小さくなります)、「毎回3日ズレる」のような固定パターンとしても覚えられません。
DatePeriodで月次ループを回すと2月が消える
この仕様が一番厄介なのは、DatePeriodで毎月の日付を作るループに紛れ込んだときです。起点が31日だと、日数が足りない月がまるごと飛ばされてしまいます。
$start = new DateTimeImmutable('2026-01-31');
$period = new DatePeriod($start, new DateInterval('P1M'), new DateTimeImmutable('2026-07-01'));
foreach ($period as $dt) {
echo $dt->format('Y-m-d'), "\n";
}
// 2026-01-31
// 2026-03-03 ← 2月がまるごと消える
// 2026-04-03
// 2026-05-03
// 2026-06-03
1月から始めたつもりが2月の分だけ欠番になっていて、しかも3月以降もズレたまま「3日」基準で回り続けます。月末を起点にした定期処理をDatePeriodで組むときは、まずこの挙動を疑ったほうがいいと思います。
「その月の末日」がほしいだけなら first day of / last day of
単純に「来月の末日」や「来月の1日」が欲しいだけなら、modify()に渡す文字列を変えるだけで解決します。PHPの相対フォーマットには”first day of”と”last day of”という書き方があり、こちらは月をまたいでもオーバーフローしません。
$d = new DateTimeImmutable('2026-01-31');
echo $d->modify('last day of next month')->format('Y-m-d');
// 2026-02-28
echo $d->modify('first day of next month')->format('Y-m-d');
// 2026-02-01
締め日や支払期日の計算のように「その月の末日・1日」を求めたいだけのケースなら、こちらを使うほうが素直です。わざわざ月をまたいだ日数計算を自分で書く必要がなくなります。
日にちを保ったまま安全に月を進めたいときは
一方で「契約日から3ヶ月ごと」のように、できるだけ元の日にちを保ちたい場合もあります。その場合は加算した後に日にちがズレていないか確認し、ズレていたら加算後の月の末日に丸めるという処理を自分で書く必要があります。
function addMonthsSafely(DateTimeImmutable $date, int $months): DateTimeImmutable
{
$day = (int) $date->format('d');
$result = $date->modify(sprintf('%+d month', $months));
if ((int) $result->format('d') !== $day) {
// 元の日にちと違う = オーバーフローしたので、末日に丸め直す
$result = $result->modify('last day of previous month');
}
return $result;
}
echo addMonthsSafely(new DateTimeImmutable('2026-01-31'), 1)->format('Y-m-d');
// 2026-02-28
echo addMonthsSafely(new DateTimeImmutable('2026-01-15'), 1)->format('Y-m-d');
// 2026-02-15(オーバーフローしないのでそのまま)
加算前の日にちを覚えておいて、加算後の日にちと比較するだけなので、そこまで複雑な処理ではありません。「日にちがズレていたら丸める」という一手間を、DateTimeの外側に足してあげる形ですね。
まとめ
PHPのDateTimeの月加算は、存在しない日付を自動的に翌月へ繰り越す仕様になっていて、これはmodify()でもDateIntervalのadd()でも変わりません。月末を起点にした加算やDatePeriodでの月次ループは特に影響を受けやすく、気づかないままバッチが1ヶ月分飛んでいた、というような事故につながります。末日や1日が欲しいだけなら”last day of” / “first day of”を使い、日にちを保ったまま進めたいなら加算後にオーバーフローを検知して丸め直す、という使い分けを覚えておくと安心だと思います。
よくある質問
Q. modify(‘+1 month’)とadd(new DateInterval(‘P1M’))で挙動は違いますか?
A. 違いません。どちらも内部的には月の数値を1つ増やして、存在しない日付は自動的に翌月へ繰り越す同じロジックで処理されます。
Q. うるう年かどうかで結果は変わりますか?
A. 変わります。1月31日に1ヶ月足した場合、平年は2月が28日までなので3月3日に、うるう年は2月が29日まであるので3月2日になります。
Q. ミュータブルなDateTimeでも同じ問題は起きますか?
A. 起きます。この挙動は日付加算のパース処理自体の仕様なので、DateTimeでもDateTimeImmutableでも変わりません。