PHP DateTime::modify adding and subtracting months
Asked Answered
M

20

128

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?

Megrims answered 30/8, 2010 at 16:44 Comment(9)
What would you expect "2001-01-31" plus 1 month will be?... "2001-02-28"? "2001-03-01"?Shaunteshave
Personally I would expect it to be 2001-02-28.Megrims
Same story with strtotime() #7120277Lapointe
Yup, its quite an annoying quirk. You have read the fine print to figure out that P1M is 31 days. Don't really understand why people keep defending it as "right" behavior.Prefab
Seems like the popular opinion is that the logic should round down (to 2/28), though PHP rounds up (to 3/1)... though I prefer PHP's way, but Microsoft's Excel rounds down, pitting web developers against spreadsheet users...Roentgenotherapy
This should be a bug and no, it should not be 2001-02-28. What about doing further calculations like adding 1 month on the result later on? Then it would be 2001-03-28? No, the user should specify the desired behavior i.e. via flag. The default should be end of month in that case, another option is to create an invalid DateTime which I can check for and do further calculations as required.Wolff
SQL Server outputs 2017-03-28 00:00:00.000 for select dateadd(m, 1, '2017-02-28') whether or not the result of adding a month in php is a bug or not is tough to say. I would expect the output from SQL Server.Retrorse
There is a library that extends datetime, php Carbon, which can prevent these types of situations.Volz
@Wolff If I add another month to 2001-03-03 I obtain 2001-04-03, which is also not what I expect if I add 2 months to 2001-01-31... I find the equality month=31 days wrong. I also have no idea if the other behavious is correct, but this one surely isn't eitehr.Outdoor
M
130

Why it's not a bug:

The current behavior is correct. The following happens internally:

  1. +1 month increases the month number (originally 1) by one. This makes the date 2010-02-31.

  2. 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 with next 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:

<?php
$d = new DateTime( '2010-01-31' );
$d->modify( 'first day of next month' );
echo $d->format( 'F' ), "\n";
?>

This script will correctly output February. The following things happen when PHP processes this first day of next month stanza:

  1. next month increases the month number (originally 1) by one. This makes the date 2010-02-31.

  2. first day of sets the day number to 1, resulting in the date 2010-02-01.

Much answered 30/8, 2010 at 16:46 Comment(8)
So what you're saying is it literally adds 1 month, ignoring the days completely? So I'm assuming you might run into a similar issue with +1 year if you add it during a leap year?Megrims
@evolve, Yes, it literary adds 1 month.Much
And if you subtract 1 month after adding it, you end up with a different date entirely, I assume. That seems very unintuitive.Eulau
Awesome example about using the new stanzas in PHP 5.3 where you can use first day, last day, this month, next month and previous month.Astigmatism
imho this is a bug. a serious bug. if I want to add 31 days, I add 31 days. I I want to add a month, a month should be added, not 31 days.Acarus
@Acarus "a month should be added, not 31 days"... define "a month"... the whole issue here is whether a month difference to a day that doesn't exist should round down (Microsoft's way and popular opinion) or up (PHP's way and my opinion).Roentgenotherapy
@Acarus It's not a bug because you cannot go from January 31st to February 31st, so what do you want the function to do? It has to either round down to February 28th (or 29th in case of a leap year) or keep counting the additional days in 31 which gets you to March 3rd.Averell
The fun fact is MySQL does it the other way: DATE_ADD('2022-03-31', INTERVAL 1 MONTH) gives '2022-04-30' while PHP date_create('2022-03-31')->modify('+1 month') returns '2022-05-01'. It made my happy debugging tests which started failing on the last day of some months.Jorie
T
23

Here is another compact solution entirely using DateTime methods, modifying the object in-place without creating clones.

$dt = new DateTime('2012-01-31');

echo $dt->format('Y-m-d'), PHP_EOL;

$day = $dt->format('j');
$dt->modify('first day of +1 month');
$dt->modify('+' . (min($day, $dt->format('t')) - 1) . ' days');

echo $dt->format('Y-m-d'), PHP_EOL;

It outputs:

2012-01-31
2012-02-29
Tillman answered 20/1, 2016 at 9:25 Comment(2)
Thanks. The best solution provided here so far. You can also shorten the code to $dt->modify()->modify(). Works just as well.Junior
best solution i found, you save my time, thanksAbate
A
11

This may be useful:

echo Date("Y-m-d", strtotime("2013-01-01 +1 Month -1 Day"));
  // 2013-01-31

echo Date("Y-m-d", strtotime("2013-02-01 +1 Month -1 Day"));
  // 2013-02-28

echo Date("Y-m-d", strtotime("2013-03-01 +1 Month -1 Day"));
  // 2013-03-31

echo Date("Y-m-d", strtotime("2013-04-01 +1 Month -1 Day"));
  // 2013-04-30

echo Date("Y-m-d", strtotime("2013-05-01 +1 Month -1 Day"));
  // 2013-05-31

echo Date("Y-m-d", strtotime("2013-06-01 +1 Month -1 Day"));
  // 2013-06-30

echo Date("Y-m-d", strtotime("2013-07-01 +1 Month -1 Day"));
  // 2013-07-31

echo Date("Y-m-d", strtotime("2013-08-01 +1 Month -1 Day"));
  // 2013-08-31

echo Date("Y-m-d", strtotime("2013-09-01 +1 Month -1 Day"));
  // 2013-09-30

echo Date("Y-m-d", strtotime("2013-10-01 +1 Month -1 Day"));
  // 2013-10-31

echo Date("Y-m-d", strtotime("2013-11-01 +1 Month -1 Day"));
  // 2013-11-30

echo Date("Y-m-d", strtotime("2013-12-01 +1 Month -1 Day"));
  // 2013-12-31
Ader answered 30/3, 2013 at 7:15 Comment(2)
Not a general solution, as this only works for certain inputs, like the 1st of the month. E.g. doing this for the 30th of January leads to suffering.Mockery
Or you could do $dateTime->modify('first day of next month')->modify('-1day')Macule
T
7

My solution to the problem:

$startDate = new \DateTime( '2015-08-30' );
$endDate = clone $startDate;

$billing_count = '6';
$billing_unit = 'm';

$endDate->add( new \DateInterval( 'P' . $billing_count . strtoupper( $billing_unit ) ) );

if ( intval( $endDate->format( 'n' ) ) > ( intval( $startDate->format( 'n' ) ) + intval( $billing_count ) ) % 12 )
{
    if ( intval( $startDate->format( 'n' ) ) + intval( $billing_count ) != 12 )
    {
        $endDate->modify( 'last day of -1 month' );
    }
}
Technique answered 24/4, 2014 at 12:30 Comment(2)
The "clone" command was the solution to my variable assignment problems. Thank you for this.Lamellibranch
While I like the solution and it works in 99% of the cases I got two questions: 1. Why the check with modulo? 2. Why does it fail given the date 2021-06-04 and modifier 42 months. Here I would expect to get 2024-12-04 but it returns 2024-11-30.Tess
M
5

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:

  • 2015-01-31
  • 2015-02-28
  • 2015-03-31
  • 2015-04-30
  • 2015-05-31
  • 2015-06-30

Right away, notice that we are expecting +1 month to mean last 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:

  • 2015-01-30
  • 2015-02-28
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

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:

  • 2015-01-30
  • 2015-02-27
  • 2015-03-30
  • 2015-04-29
  • 2015-05-30
  • 2015-06-29

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 return m/$d/Y or the attractive and simple m/30/Y for all possible months. So now let's consider a third interpretation of +1 month using both dates:

Jan. 31st

  • 2015-01-31
  • 2015-03-03
  • 2015-03-31
  • 2015-05-01
  • 2015-05-31
  • 2015-07-01

Jan. 30th

  • 2015-01-30
  • 2015-03-02
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

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:

  • never overlap
  • are always on the same date when that month has the date (so the Jan. 30 set looks pretty clean)
  • are all within 3 days (1 day in most cases) of what might be considered the "correct" date.
  • are all at least 28 days (a lunar month) from their successor and predecessor, so very evenly distributed.

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:

foreach(range(0,5) as $count) {
    $new_date = clone $date;
    $new_date->modify("+$count month");
    $expected_month = $count + 1;
    $actual_month = $new_date->format("m");
    if($expected_month != $actual_month) {
        $new_date = clone $date;
        $new_date->modify("+". ($count - 1) . " month");
        $new_date->modify("+4 weeks");
    }
    
    echo "* " . nl2br($new_date->format("Y-m-d") . PHP_EOL);
}

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

  • 2015-01-31
  • 2015-02-28
  • 2015-03-31
  • 2015-04-28
  • 2015-05-31
  • 2015-06-28

Jan. 30th

  • 2015-01-30
  • 2015-02-27
  • 2015-03-30
  • 2015-04-30
  • 2015-05-30
  • 2015-06-30

(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.)

Macule answered 13/7, 2013 at 5:16 Comment(6)
That kind of flexibility might work with a newsletter schedule, but not for scenarios that require more precision, like for example if you need to calculate whether or not a child is exactly 18 months old today.Sceptre
Maybe. But when you think about it that way, there isn't really such a thing as being "exactly 18 months old" since there isn't such a thing as an exact month. If a child is born on March 31st, what is "exactly 18 months" later? September 31st (doesn't exist), October 1st? September 30th?Macule
The only reason to know when a child is "exactly 18 months" that comes to my mind would be for their doctor, and I'm pretty sure a peditrician isn't going to lose their mind if the child came in a day earlier or later than expected. But they would be more likely to track number of days, if they did need that sort of precision. They'd want to know the child's weight at 547 days. Or perhaps in weeks (since those are always 7 days). So 78 weeks, maybe. Or 72, if we think of a month as being 4 weeks. See how precision isn't really a factor when using an arbitrary unit of measure like a month?Macule
Actually Anthony there are other valid reasons for that kind of precision. For example I need that kind of precision when calculating which day a child's daycare fee changes to the next higher fee.Sceptre
And if they were born on September 31st, what is the exact date they become 18 months?Macule
I think you mean Aug 31st, since there is no Sept 31st and because I think you're asking because February (which is six months prior to August) has only 28 days. The answer is Feb 28th, except in a leap year, it would be Feb 29th. This is quite tricky to calculate, but I did get it working. (Maybe I should post it as an answer?)Sceptre
J
5

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.

$time = new DateTime('2014-01-31');
echo $time->format('d-m-Y H:i') . '<br/>';

$time->add( add_months(1, $time));

echo $time->format('d-m-Y H:i') . '<br/>';



function add_months( $months, \DateTime $object ) {
    $next = new DateTime($object->format('d-m-Y H:i:s'));
    $next->modify('last day of +'.$months.' month');

    if( $object->format('d') > $next->format('d') ) {
        return $object->diff($next);
    } else {
        return new DateInterval('P'.$months.'M');
    }
}
Jonjona answered 18/2, 2014 at 12:1 Comment(1)
great ! I added support for substracting months if $months is < 0 and using DateTime::sub with the resulting DateInterval. php function getMonthsInterval($months, \DateTime $date): \DateInterval { $target = (clone $date)->modify("last day of {$months} month"); if ($date->format('d') > $target->format('d')) { return $date->diff($target, true); } else { return new \DateInterval("P" . abs($months) . "M"); } } Dissension
T
5

In conjunction with shamittomar's answer, it could then be this for adding months "safely":

/**
 * Adds months without jumping over last days of months
 *
 * @param \DateTime $date
 * @param int $monthsToAdd
 * @return \DateTime
 */

public function addMonths($date, $monthsToAdd) {
    $tmpDate = clone $date;
    $tmpDate->modify('first day of +'.(int) $monthsToAdd.' month');

    if($date->format('j') > $tmpDate->format('t')) {
        $daysToAdd = $tmpDate->format('t') - 1;
    }else{
        $daysToAdd = $date->format('j') - 1;
    }

    $tmpDate->modify('+ '. $daysToAdd .' days');


    return $tmpDate;
}
Trapan answered 15/1, 2016 at 11:5 Comment(1)
Thank you so much!!Naturalism
W
4

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.

public static function addMonths($monthToAdd, $date) {
    $d1 = new DateTime($date);

    $year = $d1->format('Y');
    $month = $d1->format('n');
    $day = $d1->format('d');

    if ($monthToAdd > 0) {
        $year += floor($monthToAdd/12);
    } else {
        $year += ceil($monthToAdd/12);
    }
    $monthToAdd = $monthToAdd%12;
    $month += $monthToAdd;
    if($month > 12) {
        $year ++;
        $month -= 12;
    } elseif ($month < 1 ) {
        $year --;
        $month += 12;
    }

    if(!checkdate($month, $day, $year)) {
        $d2 = DateTime::createFromFormat('Y-n-j', $year.'-'.$month.'-1');
        $d2->modify('last day of');
    }else {
        $d2 = DateTime::createFromFormat('Y-n-d', $year.'-'.$month.'-'.$day);
    }
    return $d2->format('Y-m-d');
}

For example:

addMonths(-25, '2017-03-31')

will output:

'2015-02-28'
Washy answered 24/2, 2017 at 14:55 Comment(1)
Brilliant. Works with both adding and subtracting months, taking into account different month lengths and leap years.Sceptre
M
2

I found a shorter way around it using the following code:

                   $datetime = new DateTime("2014-01-31");
                    $month = $datetime->format('n'); //without zeroes
                    $day = $datetime->format('j'); //without zeroes

                    if($day == 31){
                        $datetime->modify('last day of next month');
                    }else if($day == 29 || $day == 30){
                        if($month == 1){
                            $datetime->modify('last day of next month');                                
                        }else{
                            $datetime->modify('+1 month');                                
                        }
                    }else{
                        $datetime->modify('+1 month');
                    }
echo $datetime->format('Y-m-d H:i:s');
Moritz answered 19/9, 2014 at 3:44 Comment(0)
E
2
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');
Embus answered 19/8, 2016 at 21:49 Comment(2)
You need to explain your answer. Code only answers are considered low qualityHernadez
Thank you! This is the neatest answer yet. If you switch the last 2 lines then it always gives the correct month. Kudos!Carnassial
G
1

Here is an implementation of an improved version of Juhana's answer in a related question:

<?php
function sameDateNextMonth(DateTime $createdDate, DateTime $currentDate) {
    $addMon = clone $currentDate;
    $addMon->add(new DateInterval("P1M"));

    $nextMon = clone $currentDate;
    $nextMon->modify("last day of next month");

    if ($addMon->format("n") == $nextMon->format("n")) {
        $recurDay = $createdDate->format("j");
        $daysInMon = $addMon->format("t");
        $currentDay = $currentDate->format("j");
        if ($recurDay > $currentDay && $recurDay <= $daysInMon) {
            $addMon->setDate($addMon->format("Y"), $addMon->format("n"), $recurDay);
        }
        return $addMon;
    } else {
        return $nextMon;
    }
}

This version takes $createdDate under the presumption that you are dealing with a recurring monthly period, such as a subscription, that started on a specific date, such as the 31st. It always takes $createdDate so late "recurs on" dates won't shift to lower values as they are pushed forward thru lesser-valued months (e.g., so all 29th, 30th or 31st recur dates won't eventually get stuck on the 28th after passing thru a non-leap-year February).

Here is some driver code to test the algorithm:

$createdDate = new DateTime("2015-03-31");
echo "created date = " . $createdDate->format("Y-m-d") . PHP_EOL;

$next = sameDateNextMonth($createdDate, $createdDate);
echo "   next date = " . $next->format("Y-m-d") . PHP_EOL;

foreach(range(1, 12) as $i) {
    $next = sameDateNextMonth($createdDate, $next);
    echo "   next date = " . $next->format("Y-m-d") . PHP_EOL;
}

Which outputs:

created date = 2015-03-31
   next date = 2015-04-30
   next date = 2015-05-31
   next date = 2015-06-30
   next date = 2015-07-31
   next date = 2015-08-31
   next date = 2015-09-30
   next date = 2015-10-31
   next date = 2015-11-30
   next date = 2015-12-31
   next date = 2016-01-31
   next date = 2016-02-29
   next date = 2016-03-31
   next date = 2016-04-30
Gallinule answered 1/4, 2015 at 20:1 Comment(0)
T
0

If you just want to avoid skipping a month you can perform something like this to get the date out and run a loop on the next month reducing the date by one and rechecking until a valid date where $starting_calculated is a valid string for strtotime (i.e. mysql datetime or "now"). This finds the very end of the month at 1 minute to midnight instead of skipping the month.

    $start_dt = $starting_calculated;

    $next_month = date("m",strtotime("+1 month",strtotime($start_dt)));
    $next_month_year = date("Y",strtotime("+1 month",strtotime($start_dt)));

    $date_of_month = date("d",$starting_calculated);

    if($date_of_month>28){
        $check_date = false;
        while(!$check_date){
            $check_date = checkdate($next_month,$date_of_month,$next_month_year);
            $date_of_month--;
        }
        $date_of_month++;
        $next_d = $date_of_month;
    }else{
        $next_d = "d";
    }
    $end_dt = date("Y-m-$next_d 23:59:59",strtotime("+1 month"));
Treachery answered 1/2, 2013 at 20:59 Comment(0)
K
0

Extension for DateTime class which solves problem of adding or subtracting months

https://gist.github.com/66Ton99/60571ee49bf1906aaa1c

Kaffraria answered 13/5, 2014 at 17:32 Comment(0)
C
0

If using strtotime() just use $date = strtotime('first day of +1 month');

Casablanca answered 29/1, 2015 at 7:35 Comment(0)
N
0

I needed to get a date for 'this month last year' and it becomes unpleasant quite quickly when this month is February in a leap year. However, I believe this works... :-/ The trick seems to be to base your change on the 1st day of the month.

$this_month_last_year_end = new \DateTime();
$this_month_last_year_end->modify('first day of this month');
$this_month_last_year_end->modify('-1 year');
$this_month_last_year_end->modify('last day of this month');
$this_month_last_year_end->setTime(23, 59, 59);
Namangan answered 9/2, 2016 at 7:16 Comment(0)
C
0
$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

will output 2 (february). will work for other months too.

Cleo answered 9/8, 2017 at 5:23 Comment(0)
V
0
$current_date = new DateTime('now');
$after_3_months = $current_date->add(\DateInterval::createFromDateString('+3 months'));

For days:

$after_3_days = $current_date->add(\DateInterval::createFromDateString('+3 days'));

Important:

The method add() of DateTime class modify the object value so after calling add() on a DateTime Object it returns the new date object and also it modify the object it self.

Vicar answered 7/1, 2019 at 14:40 Comment(0)
C
0

It's a bit late but maybe it could help someone:

public static function DateSameMonth($date_iso, $add_sub_months = 1, $operator = "+") {
    $mdate = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];

    $date = new DateTime($date_iso);
    $d = (int)$date->format('d');
    $m = (int)$date->format('m');
    $y = (int)$date->format('Y');

    if ($m == 2) {
        $mdate[$m] = (($y % 4) === 0) ? (($d <= 29) ? $d : 29) : (($d <= 28) ? $d : 28);
    }

    //first day / last day 
    if($d == 1) {
        $mod = "first day of ";
    }
    elseif($d == $mdate[$m]) {
        $mod = "last day of ";
    }
    else {
        $mod = "";
    }

    $date->modify($mod . $operator . $add_sub_months . ' months');

  
    return $date->format("Y-m-d");
}

var_dump(DateSameMonth("2022-01-31", 3, "-")); //string(10) "2021-10-31"
var_dump(DateSameMonth("2022-11-30", 3, "+")); //string(10) "2023-02-28"
var_dump(DateSameMonth("2022-01-31", 25, "+")); //string(10) "2024-02-29" leap year
var_dump(DateSameMonth("2022-01-16", 25, "+")); //string(10) "2024-02-16"

Live: https://3v4l.org/hmcm6

Constitutionality answered 4/4, 2023 at 17:7 Comment(0)
W
-1

you can actually do it with just date() and strtotime() as well. For example to add 1 month to todays date:

date("Y-m-d",strtotime("+1 month",time()));

if you are wanting to use the datetime class thats fine too but this is just as easy. more details here

Welldisposed answered 15/2, 2015 at 14:0 Comment(0)
R
-2
     $date = date('Y-m-d', strtotime("+1 month"));
     echo $date;
Russ answered 5/6, 2013 at 16:4 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.