I've been working a lot with the DateTime class
and recently ran into what I thought was a bug when adding months. After a bit of research, it appears that it wasn't a bug, but instead working as intended. According to the documentation found here:
Example #2 Beware when adding or subtracting months
<?php
$date = new DateTime('2000-12-31');
$date->modify('+1 month');
echo $date->format('Y-m-d') . "\n";
$date->modify('+1 month');
echo $date->format('Y-m-d') . "\n";
?>
The above example will output: 2001-01-31 2001-03-03
Can anyone justify why this isn't considered a bug?
Furthermore does anyone have any elegant solutions to correct the issue and make it so +1 month will work as expected instead of as intended?
Why it's not a bug:
The current behavior is correct. The following happens internally:
+1 month
increases the month number (originally 1) by one. This makes the date2010-02-31
.The second month (February) only has 28 days in 2010, so PHP auto-corrects this by just continuing to count days from February 1st. You then end up at March 3rd.
How to get what you want:
To get what you want is by: manually checking the next month. Then add the number of days next month has.
I hope you can yourself code this. I am just giving what-to-do.
PHP 5.3 way:
To obtain the correct behavior, you can use one of the PHP 5.3's new functionality that introduces the relative time stanza
first day of
. This stanza can be used in combination withnext month
,fifth month
or+8 months
to go to the first day of the specified month. Instead of+1 month
from what you're doing, you can use this code to get the first day of next month like this:This script will correctly output
February
. The following things happen when PHP processes thisfirst day of next month
stanza:next month
increases the month number (originally 1) by one. This makes the date 2010-02-31.first day of
sets the day number to1
, resulting in the date 2010-02-01.This is an improved version of Kasihasi's answer in a related question. This will correctly add or subtract an arbitrary number of months to a date.
For example:
will output:
Here is another compact solution entirely using DateTime methods, modifying the object in-place without creating clones.
It outputs:
This may be useful:
I made a function that returns a DateInterval to make sure that adding a month shows the next month, and removes the days into the after that.
I agree with the sentiment of the OP that this is counter-intuitive and frustrating, but so is determining what
+1 month
means in the scenarios where this occurs. Consider these examples:You start with 2015-01-31 and want to add a month 6 times to get a scheduling cycle for sending an email newsletter. With the OP's initial expectations in mind, this would return:
Right away, notice that we are expecting
+1 month
to meanlast day of month
or, alternatively, to add 1 month per iteration but always in reference to the start point. Instead of interpreting this as "last day of month" we could read it as "31st day of next month or last available within that month". This means that we jump from April 30th to May 31st instead of to May 30th. Note that this is not because it is "last day of month" but because we want "closest available to date of start month."So suppose one of our users subscribes to another newsletter to start on 2015-01-30. What is the intuitive date for
+1 month
? One interpretation would be "30th day of next month or closest available" which would return:This would be fine except when our user gets both newsletters on the same day. Let's assume that this is a supply-side issue instead of demand-side We're not worried that the user will be annoyed with getting 2 newsletters in the same day but instead that our mail servers can't afford the bandwidth for sending twice as many newsletters. With that in mind, we return to the other interpretation of "+1 month" as "send on the second to last day of each month" which would return:
Now we've avoided any overlap with the first set, but we also end up with April and June 29th, which certainly does match our original intuitions that
+1 month
simply should returnm/$d/Y
or the attractive and simplem/30/Y
for all possible months. So now let's consider a third interpretation of+1 month
using both dates:Jan. 31st
Jan. 30th
The above has some issues. February is skipped, which could be a problem both supply-end (say if there is a monthly bandwidth allocation and Feb goes to waste and March gets doubled up on) and demand-end (users feel cheated out of Feb and perceive the extra March as attempt to correct mistake). On the other hand, notice that the two date sets:
Given the last two sets, it would not be difficult to simply roll back one of the dates if it falls outside of the actual following month (so roll back to Feb 28th and April 30th in the first set) and not lose any sleep over the occasional overlap and divergence from the "last day of month" vs "second to last day of month" pattern. But expecting the library to choose between "most pretty/natural", "mathematical interpretation of 02/31 and other month overflows", and "relative to first of month or last month" is always going to end with someone's expectations not being met and some schedule needing to adjust the "wrong" date to avoid the real-world problem that the "wrong" interpretation introduces.
So again, while I also would expect
+1 month
to return a date that actually is in the following month, it is not as simple as intuition and given the choices, going with math over the expectations of web developers is probably the safe choice.Here's an alternative solution that is still as clunky as any but I think has nice results:
It's not optimal but the underlying logic is : If adding 1 month results in a date other than the expected next month, scrap that date and add 4 weeks instead. Here are the results with the two test dates:
Jan. 31st
Jan. 30th
(My code is a mess and wouldn't work in a multi-year scenario. I welcome anyone to rewrite the solution with more elegant code so long as the underlying premise is kept intact, i.e. if +1 month returns a funky date, use +4 weeks instead.)