June 2, 2026
The PHP Bug That Only Appeared on the 29th of February
Ann R.
18 min read
When PHP-FPM workers die faster than the master can respawn them, your pool shrinks to zero while the server reports healthy. Here's the fix.
A finance team opens their monthly recurring revenue dashboard on the morning of March 1, 2024. The numbers look fine. The chart shows healthy growth. Nothing flags as unusual.
Two days later, customer support starts getting tickets. Subscriptions that were supposed to renew on Feb 29 weren't charged. About 600 of them. The customers had been using the service for two extra days for free because the billing job had silently skipped their renewal. The job hadn't crashed — it ran, processed thousands of other subscriptions correctly, logged its summary metrics, and went back to sleep. It just didn't find the Feb 29 customers when it looked.
Engineering investigates. The bug is in a date filter that runs daily, looking for "subscriptions that renew today." The query asks the database for everyone whose next_billing_date equals CURDATE(). The data is correct — those 600 users have next_billing_date = '2024-02-29'. The query runs on Feb 29 and finds them. The job processes them. Charges go through.
Except it didn't run on Feb 29. The cron schedule had an off-by-one bug planted three years ago that nobody noticed because, until that morning, the "today" computed by the job had always matched CURDATE() exactly. On the morning of Feb 29, 2024, the job's logic computed "today" as Feb 28 (because of a strtotime quirk involving -1 day +1 day normalization), found no matching subscriptions, and exited cleanly. The next day's run looked for users with next_billing_date = '2024-03-01', and the Feb 29 subscriptions weren't in that bucket either. They were never picked up.
The fix takes two lines. The audit — finding every Feb 29 subscriber, recalculating what they should have been charged, applying the missing invoices, drafting the support email — takes three weeks. Nobody had ever tested the billing job against a leap day because the job had only been deployed since 2021. The first opportunity to exercise this code path was the morning it failed.
What follows is the catalog of PHP date-handling bugs that hide on Feb 29, with verified behavior on PHP 8.3. Some are language-level quirks (strtotime('+1 month') doing something other than what most developers expect). Some are domain logic bugs that only fire on edge dates. All of them have one thing in common: they're rarely caught in testing because the test suite doesn't run on Feb 29.
TL;DR Speedrun
strtotime('2024-02-29 +1 year')returns2025-03-01, not2025-02-28. PHP's "same date next year" rolls forward when the target date doesn't exist.strtotime('2024-01-31 +1 month')returns2024-03-02, not2024-02-29. PHP adds days, not "calendar months," so Jan 31 + 1 month overflows February.new DateTime('2023-02-29')returns2023-03-01silently — no exception, no warning. Form validation built onnew DateTime()accepts every "Feb 29" of every year, valid or not.- The century rule (
year % 400 === 0) makes naive$year % 4 === 0leap year checks wrong for 2100, 2200, 2300. Bug planted now, fires in 76 years. - The safest patterns:
checkdate()for validation,DateTimeImmutable::createFromFormat()with strict format checking, anchor-day calculations for billing cycles,'first day of next month'for period boundaries.
What You'll Learn
- The seven specific Feb-29-related PHP behaviors that produce production bugs
- Why
new DateTime()is the wrong tool for validating user-supplied dates - How subscription billing drifts forward over a year when calculated naively
- The "anchor day" pattern that keeps recurring schedules stable across leap years
- A test pattern that catches leap-year bugs without waiting until 2028
Bug 1: "Plus One Year" Doesn't Roll Back
The most common Feb 29 bug. A developer writes code that adds a year to a date, assuming "same day next year." PHP doesn't see it that way.
$leapDate = '2024-02-29';
date('Y-m-d', strtotime("{$leapDate} +1 year"));
// 2025-03-01
(new DateTimeImmutable($leapDate))->modify('+1 year')->format('Y-m-d');
// 2025-03-01
(new DateTimeImmutable($leapDate))->add(new DateInterval('P1Y'))->format('Y-m-d');
// 2025-03-01$leapDate = '2024-02-29';
date('Y-m-d', strtotime("{$leapDate} +1 year"));
// 2025-03-01
(new DateTimeImmutable($leapDate))->modify('+1 year')->format('Y-m-d');
// 2025-03-01
(new DateTimeImmutable($leapDate))->add(new DateInterval('P1Y'))->format('Y-m-d');
// 2025-03-01All three approaches give the same answer: March 1. Not Feb 28. PHP treats +1 year as "advance the year by 1, keep month and day," then notices that Feb 29 doesn't exist in 2025, then rolls forward to the next valid date — which is March 1, not back to Feb 28.
This is consistent with how PHP handles all invalid-date overflows: forward, not backward. But it's not consistent with what most calendar systems would call "an anniversary." A user born on Feb 29 doesn't generally think their birthday in 2025 falls on March 1. A subscription that started Feb 29, 2024 with annual renewal — does it renew Feb 28, 2025 or March 1? The legal answer varies by jurisdiction. The PHP default is March 1.
If "March 1 for the renewal date" is what you want, this is fine. If you want Feb 28 (common in subscription billing), you need explicit handling:
function addOneYearClampToFeb(DateTimeImmutable $date): DateTimeImmutable
{
$year = (int)$date->format('Y') + 1;
$month = (int)$date->format('m');
$day = (int)$date->format('d');
// Check if the target date is valid; if not, clamp to last day of that month
if (!checkdate($month, $day, $year)) {
$day = (int)(new DateTimeImmutable("{$year}-{$month}-01"))->format('t');
}
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}function addOneYearClampToFeb(DateTimeImmutable $date): DateTimeImmutable
{
$year = (int)$date->format('Y') + 1;
$month = (int)$date->format('m');
$day = (int)$date->format('d');
// Check if the target date is valid; if not, clamp to last day of that month
if (!checkdate($month, $day, $year)) {
$day = (int)(new DateTimeImmutable("{$year}-{$month}-01"))->format('t');
}
return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
}This explicitly clamps Feb 29, 2024 → Feb 28, 2025 instead of rolling forward to March 1.
Bug 2: "Plus One Month" Is Worse
If +1 year is surprising, +1 month is dangerous. PHP doesn't have a concept of "calendar month arithmetic." +1 month adds whatever PHP considers "a month" worth of days and then normalizes:
echo date('Y-m-d', strtotime('2024-01-30 +1 month')); // 2024-03-01
echo date('Y-m-d', strtotime('2024-01-31 +1 month')); // 2024-03-02
echo date('Y-m-d', strtotime('2025-01-30 +1 month')); // 2025-03-02
echo date('Y-m-d', strtotime('2025-01-31 +1 month')); // 2025-03-03echo date('Y-m-d', strtotime('2024-01-30 +1 month')); // 2024-03-01
echo date('Y-m-d', strtotime('2024-01-31 +1 month')); // 2024-03-02
echo date('Y-m-d', strtotime('2025-01-30 +1 month')); // 2025-03-02
echo date('Y-m-d', strtotime('2025-01-31 +1 month')); // 2025-03-03Jan 31 + 1 month becomes March 2 in a leap year, March 3 in a non-leap year. The "month" being added is 31 days; February doesn't have 31 days; so the result overflows past February entirely.
The reverse direction is just as bad:
echo date('Y-m-d', strtotime('2024-03-31 -1 month')); // 2024-03-02echo date('Y-m-d', strtotime('2024-03-31 -1 month')); // 2024-03-02March 31 minus 1 month does not land in February. It lands in March. The "previous month" of March 31 is March 2, two days into the same month. Any code that uses -1 month to compute "the same date in the previous billing period" produces nonsense whenever the source date is the 29th, 30th, or 31st.
The fix depends on what "next month" means in your domain:
// "First day of next month" - useful for period boundaries
$nextMonthStart = $date->modify('first day of next month');
// "Same day of next month, clamped to month length"
function sameDayNextMonth(DateTimeImmutable $date): DateTimeImmutable
{
$current = (int)$date->format('d');
$nextMonth = $date->modify('first day of next month');
$daysInNextMonth = (int)$nextMonth->format('t');
$targetDay = min($current, $daysInNextMonth);
return $nextMonth->setDate(
(int)$nextMonth->format('Y'),
(int)$nextMonth->format('m'),
$targetDay,
);
}
// "Exactly 30 days later" - if that's actually what you mean
$thirtyDaysLater = $date->modify('+30 days');// "First day of next month" - useful for period boundaries
$nextMonthStart = $date->modify('first day of next month');
// "Same day of next month, clamped to month length"
function sameDayNextMonth(DateTimeImmutable $date): DateTimeImmutable
{
$current = (int)$date->format('d');
$nextMonth = $date->modify('first day of next month');
$daysInNextMonth = (int)$nextMonth->format('t');
$targetDay = min($current, $daysInNextMonth);
return $nextMonth->setDate(
(int)$nextMonth->format('Y'),
(int)$nextMonth->format('m'),
$targetDay,
);
}
// "Exactly 30 days later" - if that's actually what you mean
$thirtyDaysLater = $date->modify('+30 days');Three different functions for three different ideas. PHP's bare +1 month doesn't reliably do any of them. Reach for the explicit version that matches the actual intent.
Bug 3: DateTime Silently Accepts Invalid Dates
The most insidious bug because it's invisible. new DateTime() accepts strings that aren't valid dates, normalizes them silently, and returns a DateTime object that looks correct.
$dt = new DateTime('2023-02-29'); // not a leap year
echo $dt->format('Y-m-d'); // 2023-03-01
$dt = new DateTime('2023-02-30'); // never a valid date
echo $dt->format('Y-m-d'); // 2023-03-02
$dt = new DateTime('2024-04-31'); // April has 30 days
echo $dt->format('Y-m-d'); // 2024-05-01$dt = new DateTime('2023-02-29'); // not a leap year
echo $dt->format('Y-m-d'); // 2023-03-01
$dt = new DateTime('2023-02-30'); // never a valid date
echo $dt->format('Y-m-d'); // 2023-03-02
$dt = new DateTime('2024-04-31'); // April has 30 days
echo $dt->format('Y-m-d'); // 2024-05-01No exception. No warning. The object exists. Code that uses new DateTime() to "validate" a user-submitted date string accepts garbage and stores garbage:
// Common (broken) pattern in form handlers
try {
$birthdate = new DateTime($request->input('birthdate'));
$user->update(['birthdate' => $birthdate]);
return response()->json(['ok' => true]);
} catch (Exception $e) {
return response()->json(['error' => 'Invalid date'], 422);
}// Common (broken) pattern in form handlers
try {
$birthdate = new DateTime($request->input('birthdate'));
$user->update(['birthdate' => $birthdate]);
return response()->json(['ok' => true]);
} catch (Exception $e) {
return response()->json(['error' => 'Invalid date'], 422);
}A user submits 2023-02-29. The constructor returns 2023-03-01. The catch block never runs. The wrong date gets saved. The user's birthday is now March 1 in the database, and nobody will notice until the user themselves checks and complains.
The fix is createFromFormat with a strict roundtrip check, or checkdate() after explicit parsing:
function isValidDate(string $input, string $format = 'Y-m-d'): bool
{
$dt = DateTimeImmutable::createFromFormat($format, $input);
if ($dt === false) return false;
$errors = DateTimeImmutable::getLastErrors();
if ($errors !== false && ($errors['error_count'] > 0 || $errors['warning_count'] > 0)) {
return false;
}
// Final defense: roundtrip the format. If the input normalized to something
// different, the formatted result won't match the original input.
return $dt->format($format) === $input;
}
// Or simpler if you have the components parsed:
function isValidComponents(int $year, int $month, int $day): bool
{
return checkdate($month, $day, $year);
}function isValidDate(string $input, string $format = 'Y-m-d'): bool
{
$dt = DateTimeImmutable::createFromFormat($format, $input);
if ($dt === false) return false;
$errors = DateTimeImmutable::getLastErrors();
if ($errors !== false && ($errors['error_count'] > 0 || $errors['warning_count'] > 0)) {
return false;
}
// Final defense: roundtrip the format. If the input normalized to something
// different, the formatted result won't match the original input.
return $dt->format($format) === $input;
}
// Or simpler if you have the components parsed:
function isValidComponents(int $year, int $month, int $day): bool
{
return checkdate($month, $day, $year);
}Verified behavior:
isValidDate('2024-02-29') → true (valid leap day)
isValidDate('2023-02-29') → false (not a leap year)
isValidDate('2023-02-30') → false (never valid)
isValidDate('2024-04-31') → false (April has 30 days)
isValidDate('2024-13-01') → false (invalid month)isValidDate('2024-02-29') → true (valid leap day)
isValidDate('2023-02-29') → false (not a leap year)
isValidDate('2023-02-30') → false (never valid)
isValidDate('2024-04-31') → false (April has 30 days)
isValidDate('2024-13-01') → false (invalid month)The roundtrip check is the key. PHP normalizes invalid input silently; the formatted output of the normalized result won't match the original input, so equality after roundtrip catches it. checkdate() is the older and arguably cleaner approach when you have the date components separately.
Bug 4: The Century Rule
Most developers know "leap years are divisible by 4." Fewer know the exceptions:
- Divisible by 4 → leap year
- Divisible by 100 → NOT leap year (exception to the above)
- Divisible by 400 → leap year (exception to the exception)
So 2000 was a leap year (divisible by 400). 1900 wasn't (divisible by 100, not 400). 2100 won't be. 2400 will be.
Naive code that only checks $year % 4 === 0 is correct for years 1901–2099, which covers every developer's career so far. It's wrong for 1900 and will be wrong for 2100. Verified:
function naiveIsLeap(int $year): bool {
return $year % 4 === 0;
}
function correctIsLeap(int $year): bool {
return $year % 4 === 0 && ($year % 100 !== 0 || $year % 400 === 0);
}
// Comparison
naiveIsLeap(2100) // true (wrong)
correctIsLeap(2100) // false (correct)
checkdate(2, 29, 2100) // false (PHP knows the rule)function naiveIsLeap(int $year): bool {
return $year % 4 === 0;
}
function correctIsLeap(int $year): bool {
return $year % 4 === 0 && ($year % 100 !== 0 || $year % 400 === 0);
}
// Comparison
naiveIsLeap(2100) // true (wrong)
correctIsLeap(2100) // false (correct)
checkdate(2, 29, 2100) // false (PHP knows the rule)The good news: PHP's built-in checkdate() knows the full rule. Code that uses checkdate() instead of rolling its own leap-year check is automatically correct, even past 2100. The bad news: a depressing amount of code in the wild has hand-rolled if ($year % 4 == 0) checks because somebody thought they remembered the rule.
This is a long-fuse bug. Codebases written today with the naive check will quietly malfunction in 2100. Probably no current employee will be around when it happens. The bug will be hard to find precisely because it has been correct for every test case anyone has ever run against it.
The fix is one line:
// Instead of:
if ($year % 4 === 0) { ... }
// Use:
if (checkdate(2, 29, $year)) { ... }// Instead of:
if ($year % 4 === 0) { ... }
// Use:
if (checkdate(2, 29, $year)) { ... }checkdate() consults the actual Gregorian calendar rules. Use it whenever leap-year-ness matters.
Bug 5: Subscription Billing Drifts Forward
This is the bug class with real money attached. A user signs up for a monthly subscription on Feb 29, 2024. The code stores next_billing_date = 2024-02-29. The naive next-billing calculation:
$next = $subscription->next_billing_date->modify('+1 month');$next = $subscription->next_billing_date->modify('+1 month');Walked forward over 13 cycles:
Sign-up: 2024-02-29
Cycle 1: 2024-03-29 ← correct
Cycle 2: 2024-04-29 ← correct
...
Cycle 11: 2025-01-29 ← correct
Cycle 12: 2025-03-01 ← !!! should be 2025-02-28
Cycle 13: 2025-04-01 ← drifted from day 29
Cycle 14: 2025-05-01 ← still driftingSign-up: 2024-02-29
Cycle 1: 2024-03-29 ← correct
Cycle 2: 2024-04-29 ← correct
...
Cycle 11: 2025-01-29 ← correct
Cycle 12: 2025-03-01 ← !!! should be 2025-02-28
Cycle 13: 2025-04-01 ← drifted from day 29
Cycle 14: 2025-05-01 ← still driftingThe drift happens at cycle 12 because Jan 29 + 1 month overflows February (which has 28 days in 2025, no Feb 29 exists). PHP rolls forward to March 1, and now the billing cycle anchors to day 1 instead of day 29. Forever.
The user was supposed to be billed on the 29th every month. Now they're billed on the 1st. They got two free days in February 2025 and the billing schedule never recovers. A year later it's still wrong.
The fix is the anchor-day pattern: store the original day-of-month separately from the next billing date, recalculate each cycle from the anchor instead of from the previous billing date:
final class BillingCycle
{
public function __construct(
public readonly int $anchorDay, // 29 for Feb 29 signups
public readonly DateTimeImmutable $start,
) {}
public function nextBillingDate(DateTimeImmutable $current): DateTimeImmutable
{
$year = (int)$current->format('Y');
$month = (int)$current->format('m');
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) {
$nextMonth = 1;
$nextYear++;
}
// Clamp anchor day to actual length of next month
$firstOfNextMonth = new DateTimeImmutable("{$nextYear}-{$nextMonth}-01");
$daysInNextMonth = (int)$firstOfNextMonth->format('t');
$billingDay = min($this->anchorDay, $daysInNextMonth);
return new DateTimeImmutable(
sprintf('%04d-%02d-%02d', $nextYear, $nextMonth, $billingDay)
);
}
}final class BillingCycle
{
public function __construct(
public readonly int $anchorDay, // 29 for Feb 29 signups
public readonly DateTimeImmutable $start,
) {}
public function nextBillingDate(DateTimeImmutable $current): DateTimeImmutable
{
$year = (int)$current->format('Y');
$month = (int)$current->format('m');
$nextMonth = $month + 1;
$nextYear = $year;
if ($nextMonth > 12) {
$nextMonth = 1;
$nextYear++;
}
// Clamp anchor day to actual length of next month
$firstOfNextMonth = new DateTimeImmutable("{$nextYear}-{$nextMonth}-01");
$daysInNextMonth = (int)$firstOfNextMonth->format('t');
$billingDay = min($this->anchorDay, $daysInNextMonth);
return new DateTimeImmutable(
sprintf('%04d-%02d-%02d', $nextYear, $nextMonth, $billingDay)
);
}
}Verified across 13 cycles starting Feb 29, 2024 with anchor day 29:
Cycle 11: 2025-01-29
Cycle 12: 2025-02-28 ← clamped (Feb 2025 has no day 29)
Cycle 13: 2025-03-29 ← back to anchor day
Cycle 14: 2025-04-29 ← stableCycle 11: 2025-01-29
Cycle 12: 2025-02-28 ← clamped (Feb 2025 has no day 29)
Cycle 13: 2025-03-29 ← back to anchor day
Cycle 14: 2025-04-29 ← stableThe schedule clamps to Feb 28 in non-leap years and immediately returns to day 29 the next month. No drift. The 12-month cycle is correct: Feb 29 → Feb 28 → Feb 29 next leap year, every other month on day 29.
Bug 6: Age Calculation Off-By-One
Less critical than billing but legally significant: when is a Feb 29-born person "one year older" in a non-leap year?
$born = new DateTimeImmutable('2000-02-29');
$now = new DateTimeImmutable('2025-02-28');
$age = (int)$now->diff($born)->format('%y');
echo $age; // 24 - diff() says they're not yet 25$born = new DateTimeImmutable('2000-02-29');
$now = new DateTimeImmutable('2025-02-28');
$age = (int)$now->diff($born)->format('%y');
echo $age; // 24 - diff() says they're not yet 25PHP's diff() treats their "25th birthday" as March 1, 2025 (rolling forward), so Feb 28 returns age 24. UK and US law typically considers Feb 28 the legal birthday in non-leap years (one year completes the day before March 1). Saudi Arabia and several other jurisdictions agree with PHP — March 1.
Whichever rule your application needs to follow, PHP's default may not match. The fix is explicit:
function legalAge(DateTimeImmutable $born, DateTimeImmutable $now): int
{
$age = (int)$now->format('Y') - (int)$born->format('Y');
// Adjust if birthday hasn't yet occurred this year
$birthdayThisYear = (int)$born->format('m') * 100 + (int)$born->format('d');
$todayMD = (int)$now->format('m') * 100 + (int)$now->format('d');
if ($todayMD < $birthdayThisYear) {
$age--;
}
// Handle Feb 29 birthday in non-leap year
// If they were born Feb 29 and today is Feb 28 of a non-leap year,
// some jurisdictions consider this their birthday
if ($born->format('m-d') === '02-29' &&
$now->format('m-d') === '02-28' &&
!checkdate(2, 29, (int)$now->format('Y'))) {
$age++; // UK/US-style rule
}
return $age;
}function legalAge(DateTimeImmutable $born, DateTimeImmutable $now): int
{
$age = (int)$now->format('Y') - (int)$born->format('Y');
// Adjust if birthday hasn't yet occurred this year
$birthdayThisYear = (int)$born->format('m') * 100 + (int)$born->format('d');
$todayMD = (int)$now->format('m') * 100 + (int)$now->format('d');
if ($todayMD < $birthdayThisYear) {
$age--;
}
// Handle Feb 29 birthday in non-leap year
// If they were born Feb 29 and today is Feb 28 of a non-leap year,
// some jurisdictions consider this their birthday
if ($born->format('m-d') === '02-29' &&
$now->format('m-d') === '02-28' &&
!checkdate(2, 29, (int)$now->format('Y'))) {
$age++; // UK/US-style rule
}
return $age;
}This is one example. The point isn't that this specific algorithm is universally correct — it's that the default diff() behavior is opinionated, and "what age is this person" can have legal consequences (drinking age, voting age, contract eligibility). Don't rely on the default for legally-significant calculations without checking what rule actually applies in your jurisdiction.
Bug 7: "First Day of Next Month" vs "+1 Month, First Day"
A subtle one that matters for period-based reports:
$date = new DateTimeImmutable('2024-01-31');
$method1 = $date->modify('first day of next month')->format('Y-m-d');
// 2024-02-01 ← intuitive answer
$method2 = $date->modify('+1 month')->modify('first day of this month')->format('Y-m-d');
// 2024-03-01 ← wait, what?$date = new DateTimeImmutable('2024-01-31');
$method1 = $date->modify('first day of next month')->format('Y-m-d');
// 2024-02-01 ← intuitive answer
$method2 = $date->modify('+1 month')->modify('first day of this month')->format('Y-m-d');
// 2024-03-01 ← wait, what?The second version produces March 1, not February 1. Because +1 month from Jan 31 normalizes to March 2 (Jan 31 + 31 days = Mar 2 in leap year), and then "first day of this month" applies to March, giving March 1.
For "start of next month," always use first day of next month directly. Don't chain +1 month with anything else; it produces surprises.
A Testing Pattern That Catches These
The reason these bugs survive into production is that the test suite doesn't run on Feb 29. Even if the team has perfect test coverage, the testing happens on whatever day the CI runs, and "today" in the test environment is whatever the test host's clock says. Feb 29 logic doesn't get exercised unless someone explicitly tests it.
Two patterns help:
Inject the current time. Don't call now() or new DateTime() directly in production code. Receive a Clock interface (PSR-20) and inject either the real clock or a frozen clock for testing:
interface ClockInterface
{
public function now(): DateTimeImmutable;
}
class BillingService
{
public function __construct(private ClockInterface $clock) {}
public function findDueSubscriptions(): array
{
$today = $this->clock->now()->format('Y-m-d');
return Subscription::whereDate('next_billing_date', $today)->get();
}
}interface ClockInterface
{
public function now(): DateTimeImmutable;
}
class BillingService
{
public function __construct(private ClockInterface $clock) {}
public function findDueSubscriptions(): array
{
$today = $this->clock->now()->format('Y-m-d');
return Subscription::whereDate('next_billing_date', $today)->get();
}
}In tests:
public function test_billing_finds_feb_29_subscriptions(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2024-02-29 09:00:00'));
$service = new BillingService($clock);
// Test subscription with Feb 29 billing date
Subscription::factory()->create(['next_billing_date' => '2024-02-29']);
$due = $service->findDueSubscriptions();
$this->assertCount(1, $due);
}public function test_billing_finds_feb_29_subscriptions(): void
{
$clock = new FrozenClock(new DateTimeImmutable('2024-02-29 09:00:00'));
$service = new BillingService($clock);
// Test subscription with Feb 29 billing date
Subscription::factory()->create(['next_billing_date' => '2024-02-29']);
$due = $service->findDueSubscriptions();
$this->assertCount(1, $due);
}Parameterize tests across calendar edge cases. PHPUnit's @dataProvider lets you run the same test against every problematic date:
/**
* @dataProvider calendarEdgeCases
*/
public function test_billing_handles_edge_cases(string $signupDate, int $cycles, string $expectedFinal): void
{
$cycle = new BillingCycle(
anchorDay: (int)(new DateTimeImmutable($signupDate))->format('d'),
start: new DateTimeImmutable($signupDate),
);
$current = $cycle->start;
for ($i = 0; $i < $cycles; $i++) {
$current = $cycle->nextBillingDate($current);
}
$this->assertSame($expectedFinal, $current->format('Y-m-d'));
}
public static function calendarEdgeCases(): array
{
return [
'Feb 29 leap year start, 1 year forward' => ['2024-02-29', 12, '2025-02-28'],
'Jan 31 + 1 month should reach Feb end' => ['2024-01-31', 1, '2024-02-29'],
'Jan 31 + 1 month in non-leap year' => ['2025-01-31', 1, '2025-02-28'],
'Dec 31 + 1 month rolls year' => ['2024-12-31', 1, '2025-01-31'],
'Mar 31 + 1 month preserves day' => ['2024-03-31', 1, '2024-04-30'],
];
}/**
* @dataProvider calendarEdgeCases
*/
public function test_billing_handles_edge_cases(string $signupDate, int $cycles, string $expectedFinal): void
{
$cycle = new BillingCycle(
anchorDay: (int)(new DateTimeImmutable($signupDate))->format('d'),
start: new DateTimeImmutable($signupDate),
);
$current = $cycle->start;
for ($i = 0; $i < $cycles; $i++) {
$current = $cycle->nextBillingDate($current);
}
$this->assertSame($expectedFinal, $current->format('Y-m-d'));
}
public static function calendarEdgeCases(): array
{
return [
'Feb 29 leap year start, 1 year forward' => ['2024-02-29', 12, '2025-02-28'],
'Jan 31 + 1 month should reach Feb end' => ['2024-01-31', 1, '2024-02-29'],
'Jan 31 + 1 month in non-leap year' => ['2025-01-31', 1, '2025-02-28'],
'Dec 31 + 1 month rolls year' => ['2024-12-31', 1, '2025-01-31'],
'Mar 31 + 1 month preserves day' => ['2024-03-31', 1, '2024-04-30'],
];
}Each row tests a different calendar edge case. When the logic is wrong for any of them, a specific named test fails with a clear message about which scenario broke. The test suite catches the bug whether it runs in March or November; the date logic doesn't depend on the wall clock.
Pitfalls to Avoid
Using new DateTime() to validate user input. It accepts invalid dates and silently normalizes them. Use checkdate() or strict createFromFormat() with roundtrip checking. Form validation that survives a leap year audit always has explicit date validation.
Storing dates as strings without validation. A DATE column in MySQL rejects 2023-02-29 outright, throwing an error. A VARCHAR column accepts whatever string the application sends, including normalized garbage. Use proper date types in the database; let the database be the final defender.
Hardcoding leap year checks. if ($year % 4 === 0) works for the next 76 years and breaks in 2100. Use checkdate(2, 29, $year) instead. It's one character shorter and never wrong.
Calculating "next billing date" from previous billing date. Drift accumulates over months. Always recalculate from the anchor date (the original signup day) and clamp to month length. The intermediate steps don't matter; the final position must align with the anchor.
Testing only against today's date. Test suites run whenever they run; "now" in CI is rarely a leap day. Use frozen clocks and explicit date parameters in tests. If a date-handling bug requires a specific day to surface, the test should explicitly use that day, not wait for the calendar to roll around to it.
Trusting that "the framework handles it." Laravel, Symfony, Carbon — they all wrap PHP's underlying date functions, which have these behaviors. Carbon's addMonth() has the same overflow issue as raw +1 month. Read the framework's source, or run the test cases above against it, before assuming it's been handled correctly.
Mini Q&A
What's the difference between Carbon's addMonth() and addMonthNoOverflow()?
addMonth() follows PHP's default behavior — overflow rolls forward. addMonthNoOverflow() clamps to the last day of the target month. So Carbon::parse('2024-01-31')->addMonth() returns March 2; addMonthNoOverflow() returns Feb 29. The non-overflow variant matches what most billing systems actually want. If you're using Carbon, use the no-overflow variants for any business logic that involves "the same day of next month."
Should I use Unix timestamps to avoid leap year bugs?
It doesn't help. Leap year bugs aren't about timestamp arithmetic — Unix timestamps already handle leap days correctly (Feb 29 is just another day, with the next day's timestamp 86,400 seconds later). The bugs are about calendar arithmetic: "next year," "first of next month," "anniversary date." Those concepts don't exist in unix timestamps; you have to convert to calendar dates to compute them, and that's where the bugs appear.
How do other languages handle this?
Python's dateutil.relativedelta has clear semantics ("add 1 month, clamp to month end"). JavaScript's Date is famously chaotic — new Date('2023-02-29') returns March 1 silently, same as PHP, with similar arithmetic issues. C# DateTime.AddMonths clamps to month end (the no-overflow behavior PHP doesn't default to). Java's LocalDate.plusMonths also clamps. PHP is on the more permissive end of the spectrum; its built-in behavior favors "always return a date" over "fail explicitly."
What about timestamps stored in databases? Can MySQL/Postgres handle Feb 29 correctly?
The storage is fine — DATE columns in MySQL and date in PostgreSQL reject invalid dates at insert time. The arithmetic is where databases differ. MySQL's DATE_ADD('2024-01-31', INTERVAL 1 MONTH) returns 2024-02-29 (clamped, the way most users expect). PostgreSQL's equivalent returns 2024-02-29 too. So database-side date arithmetic is often more correct than PHP's. If the calculation can happen in the query, it's often safer there.
Should I just store all dates as Feb 28 if the user signs up on Feb 29?
Some companies do this. It's a defensive pattern that avoids the entire bug class — no anchor on day 29 in February, no clamping logic needed, no edge case at all. The downside is that you've now baked a small lie into your data ("their signup date is Feb 28, 2024" when it was actually Feb 29). For most applications the right answer is to store the actual date and handle the edge cases in code; for some applications, the simplification is worth it. Trade-off, not a universal answer.
Wrap-Up
Leap year bugs are interesting because they have a fixed deadline. The next opportunity to test against Feb 29 production behavior is February 29, 2028. If today is March 2, 2026, you have approximately 24 months to find and fix every Feb 29 bug in the codebase before the calendar makes the bugs visible to customers. That's plenty of time, if anyone remembers to do it.
The pattern with most of these bugs is that they're individually small (one wrong line of arithmetic) but accumulate into structural problems (a billing system that drifts, a form that accepts invalid dates, a report that's wrong every fourth February). The fixes are also individually small — checkdate() instead of % 4, anchor-day patterns instead of incremental month math, frozen clocks in tests instead of waiting for the calendar. Cumulatively they're the difference between an audit-clean date system and one that surprises somebody every leap year.
The test that catches these bugs is the boring kind: explicit date inputs, explicit expected outputs, no calendar dependency. Write it once, run it forever, the next leap year doesn't require any code changes or special preparation. The PHP version stays the same; the calendar comes around again; the tests pass; the customers don't notice anything different about Feb 29 because the system handles it the same way it handles every other day.
Closing Loop
A finance team opens their monthly recurring revenue dashboard on the morning of March 1, 2028. The numbers look fine. The chart shows healthy growth.
This time, the numbers are actually fine. The billing job ran on Feb 29 — verified via the audit log entry from the job runner — and processed 847 subscriptions whose next_billing_date was 2028-02-29. The job logic uses a frozen-clock testing pattern that exercises the exact Feb 29 code path on every commit, against deterministic test data, regardless of what day the CI runs. The anchor-day billing calculation correctly clamped non-leap-year cycles to Feb 28 and resumed day-29 cycles in subsequent months. No drift. No missed subscriptions. No support tickets.
The bug class that took three weeks to clean up after 2024 doesn't exist anymore. The audit happened. The patterns got applied. The next leap year is just another day on the calendar, handled the same way every other day is handled.
That's the whole goal. Boring, on Feb 29.
"People Also Ask"
-
Why does
strtotime('+1 year')give wrong results on Feb 29? PHP's+1 yearincrements the year and keeps month/day the same, then normalizes any invalid result by rolling forward to the next valid date. So2024-02-29 +1 yearis computed as2025-02-29(invalid), then normalized to2025-03-01. To get2025-02-28(the more common "anniversary" answer), use explicit handling withcheckdate()to detect the invalid case and clamp to the last day of February. -
Can
new DateTime()validate user-submitted dates? No.new DateTime('2023-02-29')silently returns2023-03-01without throwing an exception. Form validation built onnew DateTime()accepts every "Feb 29" of every year as valid. UseDateTimeImmutable::createFromFormat()with a strict roundtrip check (verify the formatted output matches the input), or parse the components and callcheckdate($month, $day, $year)directly.
3. How do I correctly add a month to a date in PHP? Depends on what "add a month" should mean. For "first day of next month," use $date->modify('first day of next month') — clean and unambiguous. For "same day of next month, clamped to month length," compute the next month's first day, get its t (days in month), clamp the original day to that, and reconstruct. For "exactly 30/31 days later," use $date->modify('+30 days'). The bare +1 month is rarely what anyone actually wants because it overflows February.
4. What's the correct leap year formula? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0). Divisible by 4 is a leap year, except divisible by 100 is not, except divisible by 400 is. So 2000 was leap, 1900 wasn't, 2100 won't be, 2400 will be. PHP's built-in checkdate(2, 29, $year) implements this correctly — use it instead of writing the formula yourself.
5. How do I test leap year code without waiting until 2028? Inject the current time as a ClockInterface dependency (PSR-20 defines this) instead of calling now() or new DateTime() directly. In tests, inject a FrozenClock with the specific date you want to test, including '2024-02-29 09:00:00' for leap days. Use PHPUnit's @dataProvider to parameterize tests across edge cases (Jan 31, Feb 28, Feb 29, Mar 31). The tests run on any day and exercise the leap-year code paths deterministically.
- Should I use Carbon's
addMonth()oraddMonthNoOverflow()? For business logic involving "same day of next month" (billing cycles, recurring schedules), useaddMonthNoOverflow(). It clamps to the last day of the target month:Carbon::parse('2024-01-31')->addMonthNoOverflow()returns Feb 29 (or Feb 28 in non-leap years), not March 2. The defaultaddMonth()follows PHP's overflow behavior, which is rarely what billing systems want.
7. Does storing dates as Unix timestamps avoid leap year bugs? No. Unix timestamps handle leap days correctly at the storage layer — Feb 29 has its own valid timestamp like any other date. The bugs come from calendar arithmetic ("next year same date," "one month later," "first day of next month") that you can only express by converting to calendar dates. Those conversions have the same edge cases regardless of how you store the underlying timestamp.
8. How do legal jurisdictions handle Feb 29 birthdays? Varies by jurisdiction. UK and US law generally treat Feb 28 as the legal birthday in non-leap years (a person born Feb 29 turns one year older on Feb 28 of non-leap years). Saudi Arabia, Taiwan, and several other jurisdictions consider March 1 the legal birthday in non-leap years. PHP's diff() follows the March 1 rule by default. For legally-significant calculations (drinking age, voting age, contract eligibility), encode the relevant jurisdiction's rule explicitly rather than relying on PHP's default behavior.
Note: All code examples in this article were verified against PHP 8.3.6. The specific behaviors (+1 year rolling forward from Feb 29, +1 month overflowing February from Jan 31, new DateTime() silently normalizing invalid dates) are stable across PHP 8.x and most of PHP 7.x. Carbon-specific recommendations apply to Carbon 2.x and 3.x. For production code involving recurring billing or other legally-significant date calculations, validate the specific behavior against your application's PHP version and run the test suite outlined above before deploying.