PHP DateTime:: изменить время добавления и вычитания

Я много работал с DateTime class и недавно столкнулся с тем, что, по моему мнению, было ошибкой при добавлении нескольких месяцев. После небольшого исследования выяснилось, что это не ошибка, а работа по назначению. Согласно документации, найденной здесь:

Пример # 2 Остерегайтесь при добавлении или вычитание месяцев

<?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

Может ли кто-нибудь обосновать, почему это не считается ошибкой?

Кроме того, у кого-нибудь есть изящные решения, чтобы исправить проблему и сделать так, чтобы +1 месяц работал как ожидалось, а не как предполагалось?

Ответ 1

Почему это не ошибка:

Текущее поведение правильное. Внутри внутри происходит следующее:

  • +1 month увеличивает номер месяца (первоначально 1) на единицу. Это делает дату 2010-02-31.

  • Второй месяц (февраль) имеет только 28 дней в 2010 году, поэтому PHP автоматически исправляет это, просто продолжая считать дни с 1 февраля. Затем вы закончите 3 марта.

Как получить то, что вы хотите:

Чтобы получить то, что вы хотите, выполните следующие действия: вручную проверив следующий месяц. Затем добавьте количество дней в следующем месяце.

Надеюсь, вы сами можете это кодировать. Я просто даю что-то делать.

PHP 5.3 путь:

Чтобы получить правильное поведение, вы можете использовать одну из новых функций PHP 5.3, которая вводит относительную шкалу времени first day of. Эта строфа может использоваться в комбинации с next month, fifth month или +8 months, чтобы перейти к первому дню указанного месяца. Вместо +1 month от того, что вы делаете, вы можете использовать этот код, чтобы получить первый день следующего месяца следующим образом:

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

Этот script будет правильно выводить February. Следующие события происходят, когда PHP обрабатывает эту строфу first day of next month:

  • next month увеличивает номер месяца (первоначально 1) на единицу. Это делает дату 2010-02-31.

  • first day of устанавливает номер дня в 1, в результате чего дата 2010-02-01.

Ответ 2

Это может быть полезно:

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

Ответ 3

Мое решение проблемы:

$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' );
    }
}

Ответ 4

Я создал функцию, которая возвращает DateInterval, чтобы убедиться, что добавление месяца показывает следующий месяц и удаляет дни после этого.

$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');
    }
}

Ответ 5

Я нашел более короткий путь вокруг него, используя следующий код:

                   $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');

Ответ 6

Вот реализация улучшенной версии Juhana answer в связанном с этим вопросе:

<?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;
    }
}

Эта версия принимает $createdDate в соответствии с предположением о том, что вы имеете дело с повторяющимся месячным периодом, таким как подписка, которая начиналась в определенную дату, например 31-й. Это всегда занимает $createdDate, так что поздние "повторяющиеся" даты не будут меняться на более низкие значения, поскольку они продвигаются вперед по сравнению с меньшими месяцами (например, поэтому все 29, 30 или 31 даты повторения не будут в конечном итоге зависеть от 28-й после прохождения через февраль без високосного года).

Вот пример кода для проверки алгоритма:

$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;
}

Какие выходы:

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

Ответ 7

В сочетании с ответом shamittomar, это может быть так для добавления месяцев "безопасно":

/**
 * 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;
}

Ответ 8

Если вы просто хотите избежать пропусков в месяц, вы можете выполнить что-то подобное, чтобы получить дату и запустить цикл в следующем месяце, уменьшая дату на единицу и перепроверяя до допустимой даты, когда $start_calculated является допустимой строкой для strtotime (т.е. mysql datetime или "now" ). Это находит конец конца месяца с 1 минутой до полуночи, а не пропускает месяц.

    $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"));

Ответ 9

Я согласен с чувством OP, что это противоречит интуиции и разочарованию, но так же определяет, что +1 month означает в сценариях, где это происходит. Рассмотрим следующие примеры:

Вы начинаете с 2015-01-31 и хотите добавить месяц 6 раз, чтобы получить цикл планирования отправки бюллетеня по электронной почте. Учитывая первоначальные ожидания OP, это вернет:

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

Сразу же заметите, что мы ожидаем, что +1 month означает last day of month или, альтернативно, добавить 1 месяц на итерацию, но всегда ссылаться на начальную точку. Вместо того, чтобы интерпретировать это как "последний день месяца" , мы могли бы прочитать его как "31-й день следующего месяца или последний доступный в течение этого месяца". Это означает, что мы прыгаем с 30 апреля по 31 мая вместо 30 мая. Обратите внимание, что это не потому, что это "последний день месяца" , а потому, что мы хотим "ближе всего к дате начала месяца".

Итак, предположим, что один из наших пользователей подписался на другой информационный бюллетень, который начнется в 2015-01-30 годах. Какова интуитивная дата для +1 month? Одна интерпретация будет "30-й день следующего месяца или ближайший доступный", который будет возвращаться:

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

Это было бы хорошо, если бы наш пользователь не получал оба бюллетеня в тот же день. Предположим, что это проблема со стороны предложения, а не с точки зрения спроса. Мы не беспокоимся о том, что пользователь будет раздражен тем, что получает 2 информационных бюллетеня в тот же день, но вместо этого наши почтовые серверы не могут позволить себе пропускную способность для отправки дважды, как многие информационные бюллетени. Имея это в виду, мы возвращаемся к другой интерпретации "+1 месяц" как "отправляем по второму в последний день каждого месяца", который возвращался:

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

Теперь мы избегали какого-либо совпадения с первым набором, но мы также закончили с апрелем и 29 июня, что, безусловно, соответствует нашим первоначальным интуициям, которые +1 month просто должны вернуть m/$d/Y или привлекательным и простым m/30/Y за все возможные месяцы. Итак, рассмотрим третью интерпретацию +1 month, используя обе даты:

Январь Тридцать первый

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

Январь Тридцатые

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

Вышеупомянутые проблемы. Февраль пропущен, что может быть проблемой как с точки зрения предложения (скажем, если есть ежемесячное распределение полосы пропускания, а фев, то впустую, а март удваивается) и спрос (пользователи чувствуют себя обманутыми с февраля и воспринимают дополнительный марш как попытка исправить ошибку). С другой стороны, обратите внимание, что два набора дат:

  • никогда не перекрывается
  • всегда в тот же день, когда в этом месяце указана дата (так что набор 30 января выглядит довольно чистым)
  • все в течение 3 дней (в большинстве случаев - 1 день), что может считаться "правильной" датой.
  • все по крайней мере 28 дней (лунный месяц) от их преемника и предшественника, поэтому очень равномерно распределены.

Учитывая последние два набора, нетрудно просто отбросить одну из дат, если она выпадет за пределы фактического следующего месяца (поэтому откат назад до 28 февраля и 30 апреля в первом наборе) и не потерять ни одного сон по поводу случайного совпадения и расхождения с шаблоном "последний день месяца" против "второго по последний день месяца". Но ожидая, что библиотека будет выбирать между "самой красивой/естественной", "математической интерпретацией 02/31 и другими месячными переполнениями" и "по сравнению с первым месяцем или в прошлом месяце", всегда заканчивается тем, что ожидаемые ожидания не выполняются некоторый график, необходимый для корректировки "неправильной" даты, чтобы избежать реальной проблемы, которую вводит "неправильная" интерпретация.

Итак, еще раз, ожидая, что +1 month вернет дату, которая на самом деле находится в следующем месяце, это не так просто, как интуиция, и учитывая выбор, идя с математикой по ожиданиям веб-разработчиков, вероятно, безопасный выбор.

Здесь альтернативное решение, которое все еще столь же неуклюже, как и все, но я думаю, имеет хорошие результаты:

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);
}

Это не оптимально, но основная логика: если добавление 1 месяца приводит к дате, отличной от ожидаемого в следующем месяце, замените эту дату и добавьте 4 недели вместо этого. Ниже приведены результаты с двумя датами тестирования:

Январь Тридцать первый

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

Январь Тридцатые

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

(Мой код - беспорядок и не будет работать в многолетнем сценарии. Я приветствую, чтобы кто-либо переписывал решение с более элегантным кодом, если базовое помещение хранится без изменений, то есть, если +1 месяц возвращает фанки date, используйте вместо этого +4 недели.)

Ответ 11

При использовании strtotime() просто используйте $date = strtotime('first day of +1 month');

Ответ 12

вы можете сделать это с помощью только даты() и strtotime(). Например, чтобы добавить 1 месяц к сегодняшней дате:

date ( "Y-m-d", strtotime ( "+ 1 месяц", время()));

если вы хотите использовать класс datetime, который тоже хорош, но это так же просто. подробнее здесь

Ответ 13

Вот еще одно компактное решение, полностью использующее методы DateTime, изменяющее объект на месте без создания клонов.

$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;

Он выводит:

2012-01-31
2012-02-29

Ответ 14

Мне нужно было получить дату "в этом месяце в прошлом году", и это становится довольно неприятным, когда в этом месяце - февраль в високосном году. Тем не менее, я считаю, что это работает...://Трюк, похоже, должен основывать ваши изменения на 1-й день месяца.

$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);

Ответ 15

$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');

Ответ 16

Это улучшенная версия ответа Kasihasi в соответствующем вопросе. Это будет правильно добавлять или вычитать произвольное количество месяцев до даты.

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');
}

Например:

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

выведет:

'2015-02-28'

Ответ 17

$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

выведет 2 (февраль). будет работать и в другие месяцы.

Ответ 18

     $date = date('Y-m-d', strtotime("+1 month"));
     echo $date;