A PHP billing job renews monthly subscriptions with modify('+1 month'), and customers who signed up on January 31 are billed on March 3; why, and how do you compute renewals correctly across time zones?
answer
- February 31 does not exist
- excess days roll into March
- last day of next month
- compute from the original anchor
- month maths in the customer's zone
basics
~20 smodify('+1 month') only increments the month number: January 31 becomes February 31, which overflows to March 3 in 2026. Compute each renewal from the original signup day, clamped to the target month's length, in the customer's time zone, then convert to UTC.
solid answer
~50 sPHP's month arithmetic adds 1 to the month and keeps the day, then normalises: 31 February 2026 does not exist, so it rolls three days on to 3 March. `add(new DateInterval('P1M'))` behaves the same way. Chaining makes it worse, because renewal dates drift once a short month has shifted them. The fix is to compute every renewal from the **anchor date**: go to the first day of the target month (`modify('first day of this month')` then `+N months`, which cannot overflow), then set the day to `min(anchorDay, daysInMonth)` using `format('t')`. For a single step, `modify('last day of next month')` also works. Do this in the **customer's** `DateTimeZone`, because a signup at 20:00 in New York on 31 January is already 1 February in UTC, and only then convert the result to UTC with `setTimezone()` for storage and scheduling.
code
php · 22 lines<?php
declare(strict_types=1);
function renewalDate(DateTimeImmutable $anchor, int $cycle): DateTimeImmutable
{
// Adding months to the 1st cannot overflow.
$month = $anchor->modify('first day of this month')->modify("+$cycle months");
$day = min((int) $anchor->format('j'), (int) $month->format('t'));
return $month->setDate((int) $month->format('Y'), (int) $month->format('n'), $day);
}
$anchor = new DateTimeImmutable('2026-01-31 00:00', new DateTimeZone('America/New_York'));
foreach ([1, 2, 3] as $cycle) {
$local = renewalDate($anchor, $cycle);
$utc = $local->setTimezone(new DateTimeZone('UTC'));
echo $local->format('Y-m-d H:i T'), ' = ', $utc->format('Y-m-d H:i'), " UTC\n";
}
// 2026-02-28 00:00 EST = 2026-02-28 05:00 UTC
// 2026-03-31 00:00 EDT = 2026-03-31 04:00 UTC
// 2026-04-30 00:00 EDT = 2026-04-30 04:00 UTCgo deeper
Recall that adding a month in PHP keeps the day number, so January 31 plus one month overflows past the end of February into March.
Explain the overflow step by step and show how first day of this month, format('t') and setDate() give a clamped date.
Design renewals from a stored anchor and cycle number in the customer's zone, convert to UTC only for storage, and test month ends, leap years and DST.
Agree the billing-day policy with the business, such as clamping versus fixed-length cycles, and make it a documented rule every service implements the same way.
## Why January 31 plus one month is March 3 PHP's relative formats treat `+1 month` as **add one to the month field**, keeping the day, and then normalise the result. The manual's own warning example goes from 2000-12-31 to 2001-01-31 and then to 2001-03-03. For a subscription that started on 31 January 2026: 1. `+1 month` produces 31 February 2026. 2. February 2026 has 28 days, so the date is 3 days past its end. 3. Normalisation rolls it forward to **3 March 2026**. `add(new DateInterval('P1M'))` does the same arithmetic. Nothing is wrong with PHP here; "one month later" is simply ambiguous, and the engine resolves it by overflowing, not by clamping. ## Why chaining makes it worse A job that computes each renewal from the **previous** renewal drifts permanently. Starting from 31 January with a clamping fix you get 28 February, and then `+1 month` from 28 February gives 28 March, not 31 March. Every later renewal stays on the 28th. The customer's billing day has changed without anyone deciding it. ## The anchor-based fix Store the **anchor**: the original signup date and time, in the customer's zone. Compute renewal *N* from the anchor directly: - Move to the first day of the anchor's month with `modify('first day of this month')`; adding months to the 1st can never overflow. - Add *N* months. - Read the length of that month with `format('t')`. - Set the day to `min(anchorDay, daysInMonth)` with `setDate()`. | Anchor | Renewal 1 | Renewal 2 | Renewal 3 | |---|---|---|---| | 2026-01-31 | 2026-02-28 | 2026-03-31 | 2026-04-30 | | 2026-01-30 | 2026-02-28 | 2026-03-30 | 2026-04-30 | | 2026-01-15 | 2026-02-15 | 2026-03-15 | 2026-04-15 | For a one-off "end of next month", the relative format `last day of next month` gives the clamped date directly; `first day of next month` is its counterpart for the start of the month. ## Where time zones come in The **calendar** belongs to the customer, not to the server: - A signup at 20:00 on 31 January in New York is 01:00 on 1 February in UTC. Computing renewals in UTC would make the anchor day the 1st, not the 31st. - Daylight saving time changes the UTC offset between renewals. A renewal at local midnight in New York is 05:00 UTC in February (EST) but 04:00 UTC in late March (EDT). Adding months in UTC would drift the local time by an hour. So the rule is: construct the anchor with `new DateTimeImmutable($text, new DateTimeZone($customerZoneId))`, do all month arithmetic in that zone, and convert the finished renewal with `setTimezone(new DateTimeZone('UTC'))` for storage and for the scheduler. ## Choosing a policy Clamping is one policy, not the only one. The business has to pick, and the code has to implement the choice explicitly: | Policy | January 31 signup renews on | Trade-off | |---|---|---| | Clamp to month end | Feb 28, Mar 31, Apr 30 | keeps the customer's day where it exists | | PHP overflow (`+1 month`) | Mar 3, then drifting | nobody chose it; avoid | | Fixed billing day (for example the 1st) | Feb 1, Mar 1, Apr 1 | simple, needs a prorated first period | | Fixed-length cycle (30 days) | Mar 2, Apr 1, May 1 | predictable length, drifts against the calendar | Whichever is chosen, it belongs in one function used by every service that shows or charges a renewal date, so the invoice, the reminder email and the account page agree. ## Checklist for renewal code 1. Store the anchor and the customer's zone identifier, not only the next due date. 2. Compute from the anchor and a cycle number, never from the previous renewal. 3. Clamp the day to the month length; never rely on overflow. 4. Do calendar maths in the customer's zone; store and schedule in UTC. 5. Use `DateTimeImmutable` so helpers cannot move the anchor. 6. Test anchors on the 29th, 30th and 31st, leap years, and zones with daylight saving time.
- Why not just compute each renewal as the previous renewal plus 'last day of next month' when the anchor is a month end?It works only for anchors on the last day of a month. An anchor on the 30th would be pushed to the 31st in long months, changing the billing day. Clamping `min(anchorDay, daysInMonth)` from the stored anchor handles every day from the 1st to the 31st with one rule.
- The scheduler runs in UTC. Why not store only the next renewal as a UTC timestamp?A UTC timestamp alone loses the customer's calendar: which day of the month they signed up on and which daylight-saving rules apply. Keep the anchor and the zone identifier as the source of truth, and derive the UTC timestamp for each cycle; the stored timestamp is then a cache, not the definition.
saying these in an interview costs you the question
- modify('+1 month') clamps January 31 to February 28
- Using add(new DateInterval('P1M')) avoids the overflow
- Computing renewals from the previous renewal is safe
- Renewal maths should run in the server's UTC zone
- A fixed 30-day interval is an acceptable monthly cycle