1. 今回の学習ゴールと練習課題(仕様)

あなたは小さな会員制サービスの開発担当です。マーケティング担当から「会員の年齢と、誕生日クーポンを送るための『次の誕生日までの日数』を一覧で出してほしい」と頼まれました。会員データの生年月日は手入力のため、表記ゆれや存在しない日付が混ざっていることがわかっています。

課題の要件仕様

実務Tips:年齢計算の法的な加算タイミングと本課題の仕様
日本の法律(「年齢計算ニ関スル法律」および民法143条)では、年齢は「誕生日前日の終了時(24:00)」に加算されます(4月1日生まれが早生まれになる理由です)。一方、一般的なWebサービスやアプリの会員システム、誕生日クーポン配信では直感的な「誕生日当日(0:00)に加算」を採用することが大半です。本課題では現場標準の「当日加算」を採用しています。また、平年の2月29日生まれの扱い(3月1日加算)は日本の法律規定およびPHP標準の内部挙動に準拠しています。

入力データ

$members = [
    ['name' => '佐藤', 'birthday' => '1990-10-16'],
    ['name' => '鈴木', 'birthday' => '2000-10-20'],
    ['name' => '高橋', 'birthday' => '2008-02-29'],
    ['name' => '田中', 'birthday' => '1985-12-31'],
    ['name' => '伊藤', 'birthday' => '2001-02-30'],
    ['name' => '渡辺', 'birthday' => '2030-01-01'],
    ['name' => '山本', 'birthday' => '1995/04/01'],
];

期待される実行結果

基準日:2026年10月16日
佐藤さん:36歳/今日が誕生日です!🎉
鈴木さん:25歳/次の誕生日まであと4日(もうすぐ!)
高橋さん:18歳/次の誕生日まであと136日
田中さん:40歳/次の誕生日まであと76日
伊藤さん:エラー(生年月日 2001-02-30 は存在しない日付か形式が不正です)
渡辺さん:エラー(生年月日 2030-01-01 が未来の日付です)
山本さん:エラー(生年月日 1995/04/01 は存在しない日付か形式が不正です)

まずはこの先を読まずに、自力で書いてみてください。15分考えてもわからなければ次へ進みましょう。

2. 初心者がハマるNGコード(Before)と解説

以下は、私が新人時代に実際に書いて先輩に真っ赤なレビューをもらったコードを再現したものです。エラーもWarningも一切出ずに動くのが、この手のバグの一番怖いところです。

<?php
// ❌ NGコード:動くけど、静かに間違える
$today = '2026-10-16';

$members = [
    ['name' => '鈴木', 'birthday' => '2000-10-20'],
    ['name' => '伊藤', 'birthday' => '2001-02-30'],
    ['name' => '山本', 'birthday' => '1995/04/01'],
];

foreach ($members as $member) {
    // NG①:西暦の引き算だけで年齢を出している
    $age = substr($today, 0, 4) - substr($member['birthday'], 0, 4);

    // NG②:strtotime は存在しない日付を勝手に繰り上げる
    $birth = new DateTime(date('Y-m-d', strtotime($member['birthday'])));

    // NG③:DateTime はミュータブル(変更可能)
    $next = $birth;
    $next->modify('+' . $age . ' years');

    echo "{$member['name']}さん:{$age}歳 / 生年月日 "
        . $birth->format('Y-m-d') . PHP_EOL;
}

実行結果はこうなります。

鈴木さん:26歳 / 生年月日 2026-10-20
伊藤さん:25歳 / 生年月日 2026-03-02
山本さん:31歳 / 生年月日 2026-04-01

NG①:西暦の引き算は「誕生日前」を無視する

鈴木さんは10月20日生まれなので、10月16日時点ではまだ25歳です。西暦の差だけを取ると、誕生日を迎える前の人を全員1歳多く数えてしまいます。年齢確認が必要なサービス(お酒・成人向けコンテンツなど)では、これは法令違反に直結しかねない重大バグです。さらに文字列同士の引き算はPHPが暗黙的に数値へ変換しているだけで、型の意図がコードから読み取れません。

NG②:strtotime は「2月30日」を黙って3月2日にする

strtotime('2001-02-30') はエラーを返さず、溢れた日数を翌月へ繰り上げて 2001-03-02 として解釈します。また 1995/04/01 のようなスラッシュ区切りも受け入れてしまうため、「不正な入力をはじく」という仕様をまったく満たせません。寛容すぎるパーサーは、入力検証の場面では敵になります。

NG③:$next = $birth は「コピー」ではない

PHPのオブジェクトは代入しても同じ実体を指します。$next->modify() を呼んだ瞬間、元の $birth まで書き換わり、生年月日が2026年になってしまいました。出力の「生年月日 2026-10-20」がその証拠です。関数に日付を渡したら中身が変わっていた、という事故は実務でも頻発します。

3. コピペで即動く模範解答コード(After)

PHP 8.3 / 8.4 で動作確認済みです。age.php として保存し、php age.php で実行すると、仕様どおりの結果が出力されます。

<?php
declare(strict_types=1);

const DATE_FORMAT = 'Y-m-d';
const TIMEZONE    = 'Asia/Tokyo';

/**
 * 'Y-m-d' 形式の文字列を厳密に日付へ変換する(不正なら null)
 */
function parseStrictDate(string $input, DateTimeZone $tz): ?DateTimeImmutable
{
    // 先頭の「!」で時刻部分を 00:00:00 にリセットする
    $date = DateTimeImmutable::createFromFormat('!' . DATE_FORMAT, $input, $tz);

    // 「2001-02-30 → 2001-03-02」のような繰り上がりを往復変換で検出
    if ($date === false || $date->format(DATE_FORMAT) !== $input) {
        return null;
    }
    return $date;
}

/**
 * 基準日時点の満年齢を返す
 */
function calculateAge(DateTimeImmutable $birthday, DateTimeImmutable $today): int
{
    return $birthday->diff($today)->y;
}

/**
 * 次の誕生日までの日数を返す(当日なら 0)
 */
function daysUntilNextBirthday(DateTimeImmutable $birthday, DateTimeImmutable $today): int
{
    $month = (int) $birthday->format('n');
    $day   = (int) $birthday->format('j');
    $year  = (int) $today->format('Y');

    // 平年の 2/29 は setDate が自動で 3/1 に繰り上げる
    $next = $today->setDate($year, $month, $day);
    if ($next < $today) {
        $next = $today->setDate($year + 1, $month, $day);
    }
    // diff() が返す DateInterval->days(総日数)を型安全に返す
    return (int) $today->diff($next)->days;
}

/**
 * 1人分の表示用メッセージを組み立てる
 */
function describeMember(string $name, string $rawBirthday, DateTimeImmutable $today): string
{
    $birthday = parseStrictDate($rawBirthday, $today->getTimezone());

    if ($birthday === null) {
        return "{$name}さん:エラー(生年月日 {$rawBirthday} は存在しない日付か形式が不正です)";
    }
    if ($birthday > $today) {
        return "{$name}さん:エラー(生年月日 {$rawBirthday} が未来の日付です)";
    }

    $age  = calculateAge($birthday, $today);
    $days = daysUntilNextBirthday($birthday, $today);

    $birthdayLabel = match (true) {
        $days === 0 => '今日が誕生日です!🎉',
        $days <= 7  => "次の誕生日まであと{$days}日(もうすぐ!)",
        default     => "次の誕生日まであと{$days}日",
    };

    return "{$name}さん:{$age}歳/{$birthdayLabel}";
}

// ---------- 実行部 ----------
$tz    = new DateTimeZone(TIMEZONE);
$today = parseStrictDate('2026-10-16', $tz)
    ?? throw new RuntimeException('基準日の形式が不正です');
// 本番環境(当日基準)では $today = new DateTimeImmutable('today', $tz); を使う('now' だと時分秒が混入するため 'today' で 00:00:00 に初期化)

$members = [
    ['name' => '佐藤', 'birthday' => '1990-10-16'],
    ['name' => '鈴木', 'birthday' => '2000-10-20'],
    ['name' => '高橋', 'birthday' => '2008-02-29'],
    ['name' => '田中', 'birthday' => '1985-12-31'],
    ['name' => '伊藤', 'birthday' => '2001-02-30'],
    ['name' => '渡辺', 'birthday' => '2030-01-01'],
    ['name' => '山本', 'birthday' => '1995/04/01'],
];

echo '基準日:' . $today->format('Y年n月j日') . PHP_EOL;
foreach ($members as $member) {
    echo describeMember($member['name'], $member['birthday'], $today) . PHP_EOL;
}

4. 現場のプロ(kixking)による徹底コード解説

Step 1:createFromFormat +「往復チェック」で厳密にパースする

DateTimeImmutable::createFromFormat() は「この形式で読め」と明示するため、1995/04/01 のような区切り文字違いは false を返して弾いてくれます。しかし実は、2001-02-30 のような存在しない日付を与えると、エラーにならず自動的に翌月へ繰り上がった日付(2001-03-02)としてオブジェクトを生成してしまいます。

そこで現場で鉄板となっているのが、生成されたオブジェクトを同じフォーマットで文字列に戻し、元の入力値と完全一致するか比較する「往復チェック(Round-trip validation)」です。$date->format(DATE_FORMAT) !== $input であれば、「勝手に日数が繰り上がった=存在しない不正な日付」と確実に判定できます。PHP標準には DateTimeImmutable::getLastErrors() で警告を取得する方法もありますが、往復チェックは数行で意図が明白に伝わり、PHP 8.x 全バージョンで安定して動く最も堅実な手法です。

フォーマット文字列の先頭に付けた ! も見落とせないポイントです。! はフォーマットで指定されていない時刻部分(時・分・秒・マイクロ秒)を Unix エポック(00:00:00)に初期化します。これがないと実行した瞬間の現在時刻(時分秒)が暗黙的に引き継がれ、日付の比較や日数差分の計算が数秒〜数時間単位でずれる原因になります。

Step 2:DateTimeImmutable で「副作用(破壊的変更)」を根絶する

従来の DateTime はミュータブル(可変)なため、$next = $birth; $next->modify(...) のように別変数へ代入しても同じインスタンスを参照し続け、元の変数の日付まで勝手に書き換わってしまいます。これは関数に日付オブジェクトを引数で渡した際に、呼び出し元の意図しないところで値が書き換わる「最悪の副作用」を引き起こします。

対して DateTimeImmutable の setDate() や modify() は、自分自身を変更せず常に新しいオブジェクトを返します。そのため $today->setDate(...) を何回呼び出しても $today の値は基準日のまま一切変わりません。PHP 8.2 以降の readonly class の普及とともに、現代のPHP開発では「特別な理由がない限り、日付はすべて DateTimeImmutable で扱う」が標準のコーディング規約です。

Step 3:年齢は diff()->y に任せ、自前で月日比較を書かない

満年齢の計算で最もやってはいけないのが、「西暦を引き算したあと、月と日を手作業で if 文比較する」自前実装です。うるう年の2月29日生まれや、各月の日数差(28日・30日・31日)の境界値で高確率でバグが混入します。

$birthday->diff($today) が返す DateInterval オブジェクトの ->y プロパティは、「満何年経過したか」をカレンダーの規則通りに正確に算出します。誕生日前であれば自動的に1歳少なくなり、誕生日当日の 00:00:00 を迎えた瞬間に1歳加算されます。複雑なカレンダー演算は標準ライブラリの堅牢なエンジンに任せるのが、シニアエンジニアの基本姿勢です。

Step 4:うるう年の繰り上がり特性を「仕様に合わせて味方にする」

入力検証(Step 1)では厳禁だった「日付の繰り上がり」ですが、setDate($year, 2, 29) を呼ぶと平年において自動的に 03-01 になるという PHP の内部挙動は、今回の「平年は3月1日を誕生日とする」という業務仕様と完全に合致しています。

「入力検証時は厳密に繰り上がりを禁止し、ビジネスロジックの算出では言語仕様を正しく理解して活用する」という使い分けが重要です。また、次の誕生日までの日数を取得する際は $today->diff($next)->days を使用します。->d は「○ヶ月と○日」の端数の日(0〜30日程度)しか返さないため、通算日数を求めるには必ず ->days を参照してください。

Step 5:タイムゾーンは必ず明示し、実行環境に依存させない

DateTimeZone を省略すると、サーバーの php.ini に設定された date.timezone(デフォルトでは UTC であることが多い)が使われます。もし UTC 環境のまま日本国内向けサービスを運用すると、日本時間の午前0時〜午前9時の間は「前日」と判定され、誕生日当日なのにクーポンが届かない・年齢が1歳少なく表示されるといった障害が発生します。日付を生成・パースする際は、常に明示的に DateTimeZone を指定する習慣を徹底しましょう。

現場のシニアメモ:最新PHP(8.3 / 8.4)における日付処理とWebセキュリティ

現代のPHPアプリケーションにおいて、日付処理とWeb入力の組み合わせはセキュリティおよび保守性の要です。

現場の鉄則:日付は「文字列のまま計算しない」「寛容なパーサーで検証しない」「ミュータブルなオブジェクトを使い回さない」。この3つを守るだけで、日付まわりのバグは激減します。

5. もう一歩進める!発展チャレンジ課題

模範解答が理解できたら、次のミニ要件に挑戦してみましょう。どれも現場の実務でそのまま求められる実践的な内容です。

  1. 年代別の集計:有効な会員を「20代」「30代」「40代以上」に分類し、それぞれの人数を連想配列で集計して出力してみましょう。intdiv($age, 10) * 10 と match 式を組み合わせるとエレガントに書けます。
  2. 誕生月クーポン対象者の抽出:基準日の「翌月」が誕生月の会員だけを array_filter() で抽出してください。12月の翌月が1月(年をまたぐ)になる点に注意して判定ロジックを組みましょう。
  3. 年齢制限チェック関数:isAdult(DateTimeImmutable $birthday, DateTimeImmutable $today): bool を作り、18歳以上なら true を返すようにしてください。誕生日当日・前日の境界値テストケースを用意して動作を確認してみましょう。
  4. フォーム入力とXSS対策:<input type="date"> から受け取った値を parseStrictDate() で検証し、エラー時は htmlspecialchars($rawBirthday, ENT_QUOTES, 'UTF-8') で安全にエスケープして画面に再表示する簡易フォームを作成してみましょう。

日付処理は、どんなWebアプリにも必ず登場する「地味だけど事故ると痛い」最重要分野です。今回のドリルで身につけた createFromFormat +往復チェック、DateTimeImmutable、diff() の3点セットは、予約システム、定期課金、有効期限チェックなど、あらゆる場面でそのまま使えます。ぜひ手元でコードを動かし、うるう年や月末などの境界値をいろいろ変えて検証してみてください。