DateTime::createFromFormatで日付をパースするときの落とし穴

DateTime::createFromFormatは、指定したフォーマットに沿って文字列を日時オブジェクトに変換するメソッドです。パースに失敗すると例外ではなくfalseを返す、という点がまず最初のハマりどころです。

フォームやCSVから受け取った日付文字列を扱うとき、なんとなくstrtotimeやnewDateTimeで済ませている人も多いと思います。ですがフォーマットが決まっているデータを扱うなら、createFromFormatのほうが明らかに安全です。ただし挙動にクセがあって、私も一度、時刻部分が意図せず「今の時刻」で埋まって本番データがおかしくなったことがあります。今日はその話です。

失敗してもfalseが返るだけで例外にはならない

createFromFormatはパースに失敗するとDateTime|falseのfalseを返します。例外を投げてくれるわけではないので、戻り値をチェックせずに->format()を呼ぶとFatal errorになります。

$date = DateTime::createFromFormat('Y-m-d', '2026-13-40不正な文字列');
if ($date === false) {
    throw new InvalidArgumentException('日付の形式が不正です');
}

falseチェックを入れておけば防げますが、忘れがちなので、日付をパースする共通処理を作るなら最初からfalseチェックをセットで書いておくのがいいと思います。

フォーマットに書かなかった部分はどう埋まる?

ここが一番ハマりやすいところです。’Y-m-d’のように日付部分しか書かないフォーマットでパースすると、時刻部分(時・分・秒)は0埋めではなく「現在時刻」で埋まります。

// 実行時刻が 2026-09-09 15:50:46 だとすると
$date = DateTime::createFromFormat('Y-m-d', '2009-02-15');
echo $date->format('Y-m-d H:i:s');
// => 2009-02-15 15:50:46 (時刻は現在時刻のまま)

「日付だけ保存したつもりが、DBに入れたら時刻がバラバラだった」という不具合は、だいたいこれが原因です。年月日だけ欲しいのに時刻を無視しているつもりでも、オブジェクトの中には現在時刻がそのまま残っています。

!と|でリセットする

これを防ぐには、フォーマット文字列の先頭に!を置くか、末尾に|を置きます。動きが少し違うので使い分けが必要です。

!をフォーマットの先頭に置くと、年月日時分秒すべてがUnixエポック(1970-01-01 00:00:00)基準の値にリセットされたうえで、フォーマットで指定した部分だけが入力値で上書きされます。

$date = DateTime::createFromFormat('!Y-m-d', '2009-02-15');
echo $date->format('Y-m-d H:i:s');
// => 2009-02-15 00:00:00

一方、|をフォーマットの末尾に置くと、「まだパースされていないフィールドだけ」がゼロ相当の値にリセットされます。’Y-m-d’のように日付しか書いていない場合は結果的に!と同じになりますが、意味合いとしては「明示していない残りを0にする」という書き方です。

$date = DateTime::createFromFormat('Y-m-d|', '2009-02-15');
echo $date->format('Y-m-d H:i:s');
// => 2009-02-15 00:00:00

日付だけを扱う処理なら、’Y-m-d|’のように末尾に|を付けるのを毎回のクセにしておくと、この手の事故はほぼなくなります。

桁あふれ(オーバーフロー)はエラーにならない

もう一つ意外な挙動が、範囲外の数値を渡してもエラーにならず、繰り上がって解釈される点です。

$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2021-17-35 16:60:97');
echo $date->format('Y-m-d H:i:s');
// => 2022-06-04 17:01:37 相当の値(月・日・分・秒がそれぞれ繰り上がった結果)

13月や32日のような「本来ありえない値」も、そのまま次の月・次の日に繰り上げて解釈されてしまいます。falseにもならないので、単純に->format()の戻り値を見ただけでは異常な入力だったことに気づけません。

getLastErrors()で本当に正しくパースできたか確認する

オーバーフローや余分な文字列(未使用のまま残った入力)を検知したい場合は、DateTime::getLastErrors()を使います。パース直後に呼ぶと、warning_count・warnings・error_count・errorsを含む配列が返ってきます。

$date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2021-17-35 16:60:97');
$errors = DateTimeImmutable::getLastErrors();

if ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) {
    // 'warnings' => [19 => 'The parsed date was invalid'] のような内容が入っている
    throw new InvalidArgumentException('日付の値が不正です');
}

入力バリデーションとして厳密に日付を検証したいなら、createFromFormatの戻り値がfalseでないことだけでなく、getLastErrors()のカウントもあわせて見る必要があります。個人的には、外部から受け取った日付文字列は全部このパターンでチェックするようにしています。

まとめ

createFromFormatは便利な反面、パース失敗時はfalseを返すだけで例外を投げないこと、フォーマットに書かなかった部分は現在時刻で埋まること、範囲外の数値もエラーにせず繰り上げて解釈してしまうことという3つの癖があります。日付だけを扱いたいなら’Y-m-d|’のように|を付けて時刻をリセットし、入力値の妥当性まで確認したいならgetLastErrors()を組み合わせる。この2点をセットで覚えておけば、ユーザー入力由来の日付処理でハマることはかなり減ると思います。

よくある質問

Q. createFromFormatとstrtotimeはどう使い分ければいいですか?
A. 入力フォーマットが決まっているならcreateFromFormat、「今日」「3日後」のような曖昧な自然文を解釈したいならstrtotimeが向いています。外部入力の検証をしたい場合はフォーマットを固定できるcreateFromFormatのほうが安全です。

Q. DateTimeとDateTimeImmutableでcreateFromFormatの挙動に違いはありますか?
A. パースのロジック自体は共通で、返ってくるオブジェクトがミュータブルかイミュータブルかの違いだけです。getLastErrors()もどちらのクラスからでも同じ結果を参照できます。

Q. getLastErrors()はいつ呼び出す必要がありますか?
A. createFromFormat(またはDateTime::__constructなど)を呼んだ直後に呼び出します。別の処理を挟むと直前のパース結果ではなくなる可能性があるので、パースした行のすぐ後に呼ぶのが安全です。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です