PHP DateTime :: modificar sumando y restando meses

101

He estado trabajando mucho con el DateTime classy recientemente encontré lo que pensé que era un error al agregar meses. Después de investigar un poco, parece que no fue un error, sino que funcionó como se esperaba. Según la documentación que se encuentra aquí :

Ejemplo n. ° 2 Tenga cuidado al sumar o restar meses

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

¿Alguien puede justificar por qué esto no se considera un error?

Además, ¿alguien tiene alguna solución elegante para corregir el problema y hacer que +1 mes funcione como se esperaba en lugar de como se esperaba?

tplaner
fuente
¿Cuál esperaría que sea "2001-01-31" más 1 mes? ... "2001-02-28"? "2001-03-01"?
Artefacto
57
Personalmente, esperaría que fuera 2001-02-28.
tplaner
La misma historia con strtotime() stackoverflow.com/questions/7119777/…
Valentin Despa
2
Sí, es una peculiaridad bastante molesta. Ha leído la letra pequeña para darse cuenta de que P1M es de 31 días. Realmente no entiendo por qué la gente sigue defendiéndolo como un comportamiento "correcto".
Indivision Dev
Parece que la opinión popular es que la lógica debería redondearse hacia abajo (a 2/28), aunque PHP redondea hacia arriba (a 3/1) ... aunque prefiero la forma de PHP, pero Excel de Microsoft redondea hacia abajo, enfrentando a los desarrolladores web con los usuarios de hojas de cálculo. ...
Dave Heq

Respuestas:

106

Por qué no es un error:

El comportamiento actual es correcto. Lo siguiente sucede internamente:

  1. +1 monthaumenta el número de mes (originalmente 1) en uno. Esto hace la fecha 2010-02-31.

  2. El segundo mes (febrero) solo tiene 28 días en 2010, por lo que PHP corrige esto automáticamente al continuar contando los días desde el 1 de febrero. Luego terminas el 3 de marzo.

Cómo conseguir lo que quieres:

Para obtener lo que desea es: verificando manualmente el mes siguiente. Luego suma el número de días que tiene el próximo mes.

Espero que puedas codificar esto tú mismo. Solo estoy dando qué hacer.

PHP 5.3 forma:

Para obtener el comportamiento correcto, puede utilizar una de las nuevas funciones de PHP 5.3 que introduce la estrofa de tiempo relativo first day of. Esta estrofa se puede usar en combinación con next month, fifth montho +8 monthspara ir al primer día del mes especificado. En lugar de +1 monthlo que está haciendo, puede usar este código para obtener el primer día del próximo mes así:

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

Este script se generará correctamente February. Las siguientes cosas suceden cuando PHP procesa esta first day of next monthestrofa:

  1. next monthaumenta el número de mes (originalmente 1) en uno. Esto hace que la fecha 2010-02-31.

  2. first day ofestablece el número de día en 1, lo que da como resultado la fecha 2010-02-01.

Shamittomar
fuente
1
Entonces, ¿lo que estás diciendo es que literalmente agrega 1 mes, ignorando los días por completo? Entonces, supongo que podría tener un problema similar con +1 año si lo agrega durante un año bisiesto.
tplaner
@evolve, sí, literario suma 1 mes.
shamittomar
13
Y si restas 1 mes después de sumarlo, terminas con una fecha completamente diferente, supongo. Eso parece muy poco intuitivo.
Dan Breen
2
Impresionante ejemplo sobre el uso de las nuevas estrofas en PHP 5.3 donde puede usar el primer día, el último día, este mes, el mes siguiente y el mes anterior.
Kim Stacks
6
En mi humilde opinión, esto es un error. un error grave. si quiero agregar 31 días, agrego 31 días. Quiero agregar un mes, se debe agregar un mes, no 31 días.
low_rents
12

Aquí hay otra solución compacta que usa completamente métodos DateTime, modificando el objeto en el lugar sin crear 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;

Produce:

2012-01-31
2012-02-29
Rudiger W.
fuente
1
Gracias. La mejor solución proporcionada aquí hasta ahora. También puede acortar el código a $dt->modify()->modify(). Funciona igual de bien.
Alph.Dev
10

Esto puede resultar útil:

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
nicolaas thiemen francken
fuente
2
No es una solución general, ya que esto solo funciona para ciertas entradas, como el día 1 del mes. Por ejemplo, hacer esto para el 30 de enero conduce al sufrimiento.
Jens Roland
O podrías hacerlo$dateTime->modify('first day of next month')->modify('-1day')
Anthony
6

Mi solución al problema:

$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' );
    }
}
bernland
fuente
3
El comando "clonar" fue la solución a mis problemas de asignación de variables. Gracias por esto.
Steph Rose
4

Estoy de acuerdo con el sentimiento del OP de que esto es contraintuitivo y frustrante, pero también lo es determinar qué +1 month significa en los escenarios en los que esto ocurre. Considere estos ejemplos:

Comienza con 2015-01-31 y desea agregar un mes 6 veces para obtener un ciclo de programación para enviar un boletín por correo electrónico. Con las expectativas iniciales del OP en mente, esto regresaría:

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

De inmediato, observe que estamos esperando +1 monthquerer decirlast day of month o, alternativamente, agregar 1 mes por iteración, pero siempre en referencia al punto de inicio. En lugar de interpretar esto como "último día del mes", podríamos leerlo como "día 31 del próximo mes o último disponible dentro de ese mes". Esto significa que saltamos del 30 de abril al 31 de mayo en lugar del 30 de mayo. Tenga en cuenta que esto no se debe a que sea el "último día del mes", sino a que queremos "el más cercano disponible a la fecha del mes de inicio".

Entonces, supongamos que uno de nuestros usuarios se suscribe a otro boletín para comenzar el 30-01-2015. ¿Para qué es la fecha intuitiva +1 month? Una interpretación sería "día 30 del próximo mes o el más cercano disponible" que devolvería:

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

Esto estaría bien, excepto cuando nuestro usuario reciba ambos boletines el mismo día. Supongamos que se trata de un problema del lado de la oferta y no del lado de la demanda.No nos preocupa que el usuario se moleste al recibir 2 boletines el mismo día, sino que nuestros servidores de correo no pueden pagar el ancho de banda para enviar el doble de muchos boletines. Con eso en mente, volvemos a la otra interpretación de "+1 mes" como "enviar el penúltimo día de cada mes", que devolvería:

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

Ahora hemos evitado cualquier superposición con el primer conjunto, pero también terminamos con el 29 de abril y junio, lo que ciertamente coincide con nuestras intuiciones originales que +1 monthsimplemente deberían regresar m/$d/Yo lo atractivo y simple m/30/Ypara todos los meses posibles. Así que ahora consideremos una tercera interpretación del +1 monthuso de ambas fechas:

31 de enero

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

30 de enero

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

Lo anterior tiene algunos problemas. Se omite febrero, lo que podría ser un problema tanto para el final de la oferta (por ejemplo, si hay una asignación mensual de ancho de banda y febrero se desperdicia y marzo se duplica) como el final de la demanda (los usuarios se sienten estafados en febrero y perciben el marzo adicional como intento de corregir el error). Por otro lado, observe que los dos conjuntos de fechas:

  • nunca se superponga
  • siempre están en la misma fecha cuando ese mes tiene la fecha (por lo que el conjunto del 30 de enero se ve bastante limpio)
  • están todos dentro de los 3 días (1 día en la mayoría de los casos) de lo que podría considerarse la fecha "correcta".
  • son todos al menos 28 días (un mes lunar) de su sucesor y predecesor, por lo que están distribuidos de manera muy uniforme.

Dados los dos últimos conjuntos, no sería difícil simplemente revertir una de las fechas si cae fuera del mes siguiente real (así que retroceda al 28 de febrero y al 30 de abril en el primer conjunto) y no perder el sueño durante el superposición ocasional y divergencia del patrón del "último día del mes" frente al patrón del "segundo al último día del mes". Pero esperar que la biblioteca elija entre "más bonito / natural", "interpretación matemática del 31/02 y otros desbordamientos del mes" y "relativo al primero o al último mes" siempre terminará con las expectativas de alguien que no se cumplan y algunos programas necesitan ajustar la fecha "incorrecta" para evitar el problema del mundo real que introduce la interpretación "incorrecta".

Entonces, nuevamente, aunque también esperaría +1 monthdevolver una fecha que en realidad es el mes siguiente, no es tan simple como la intuición y, dadas las opciones, ir con las matemáticas sobre las expectativas de los desarrolladores web es probablemente la opción segura.

Aquí hay una solución alternativa que sigue siendo tan torpe como cualquier otra, pero creo que tiene buenos resultados:

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

No es óptimo, pero la lógica subyacente es: si agregar 1 mes da como resultado una fecha diferente a la prevista para el próximo mes, elimine esa fecha y agregue 4 semanas en su lugar. Aquí están los resultados con las dos fechas de prueba:

31 de enero

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

30 de enero

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

(Mi código es un desastre y no funcionaría en un escenario de varios años. Le doy la bienvenida a cualquiera para que reescriba la solución con un código más elegante siempre que la premisa subyacente se mantenga intacta, es decir, si +1 mes devuelve una fecha original, use +4 semanas en su lugar.)

Antonio
fuente
4

Hice una función que devuelve un DateInterval para asegurarme de que al agregar un mes se muestre el mes siguiente y se eliminen los días posteriores.

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

Junto con la respuesta de shamittomar, podría ser esto para agregar meses "de forma segura":

/**
 * 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;
}
patrickzzz
fuente
¡¡Muchas gracias!!
geckos
2

Encontré una forma más corta de evitarlo usando el siguiente código:

                   $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');
Rommel Paras
fuente
1

Aquí hay una implementación de una versión mejorada de la respuesta de Juhana en una pregunta relacionada:

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

Esta versión asume $createdDateque se trata de un período mensual recurrente, como una suscripción, que comenzó en una fecha específica, como el 31. Siempre es necesario $createdDateque las fechas "recurrentes" tardías no cambien a valores más bajos a medida que avanzan a través de meses de menor valor (p. Ej., Para que todas las fechas recurrentes 29, 30 o 31 finalmente no se atasquen el día 28 después de pasar hasta febrero no bisiesto).

Aquí hay un código de controlador para probar el algoritmo:

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

Qué salidas:

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
derekm
fuente
1

Esta es una versión mejorada de la respuesta de Kasihasi en una pregunta relacionada. Esto sumará o restará correctamente una cantidad arbitraria de meses a una fecha.

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

Por ejemplo:

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

dará salida:

'2015-02-28'
Hải Phong
fuente
0

Si solo desea evitar omitir un mes, puede realizar algo como esto para obtener la fecha y ejecutar un bucle en el mes siguiente reduciendo la fecha en uno y volviendo a verificar hasta una fecha válida donde $ start_calculated es una cadena válida para strtotime (es decir mysql datetime o "ahora"). Esto encuentra el final del mes 1 minuto antes de la medianoche en lugar de omitir el mes.

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

Si usa strtotime()solo use$date = strtotime('first day of +1 month');

Primoz Roma
fuente
0

Necesitaba obtener una fecha para 'este mes el año pasado' y se vuelve desagradable bastante rápido cuando este mes es febrero en un año bisiesto. Sin embargo, creo que esto funciona ...: - / El truco parece estar en basar el cambio en el primer día del mes.

$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);
Simplemente alto
fuente
0
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');
Tim Graham
fuente
1
Necesitas explicar tu respuesta. Las respuestas de solo código se consideran de baja calidad
Machavity
¡Gracias! Esta es la mejor respuesta hasta ahora. Si cambia las últimas 2 líneas, siempre da el mes correcto. ¡Prestigio!
Danny Schoemann
0
$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

saldrá 2(febrero). también funcionará durante otros meses.

galki
fuente
0
$current_date = new DateTime('now');
$after_3_months = $current_date->add(\DateInterval::createFromDateString('+3 months'));

Por dias:

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

Importante:

El método add()de la clase DateTime modifica el valor del objeto, por lo que después de llamar add()a un objeto DateTime, devuelve el nuevo objeto de fecha y también modifica el objeto en sí.

MiharbKH
fuente
0

en realidad, puede hacerlo con solo date () y strtotime () también. Por ejemplo, para agregar 1 mes a la fecha de hoy:

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

Si desea utilizar la clase datetime, eso también está bien, pero es igual de fácil. más detalles aquí

Adicto a PHP
fuente
0

La respuesta aceptada ya explica por qué esto no es un pero, y algunas otras respuestas plantean una buena solución con expresiones php como first day of the +2 months . El problema con esas expresiones es que no se autocompletan.

Sin embargo, la solución es bastante simple. Primero, debe encontrar abstracciones útiles que reflejen el espacio de su problema. En este caso, es un ISO8601DateTime. En segundo lugar, debe haber múltiples implementaciones que puedan traer una representación textual deseada. Por ejemplo, Today, Tomorrow, The first day of this month, Future- todos representan una implementación específica del ISO8601DateTimeconcepto.

Entonces, en su caso, una implementación que necesita es TheFirstDayOfNMonthsLater. Es fácil de encontrar con solo mirar las subclases llist en cualquier IDE. Aquí está el código:

$start = new DateTimeParsedFromISO8601String('2000-12-31');
$firstDayOfOneMonthLater = new TheFirstDayOfNMonthsLater($start, 1);
$firstDayOfTwoMonthsLater = new TheFirstDayOfNMonthsLater($start, 2);
var_dump($start->value()); // 2000-12-31T00:00:00+00:00
var_dump($firstDayOfOneMonthLater->value()); // 2001-01-01T00:00:00+00:00
var_dump($firstDayOfTwoMonthsLater->value()); // 2001-02-01T00:00:00+00:00

Lo mismo ocurre con los últimos días de un mes. Para obtener más ejemplos de este enfoque, lea esto .

Vadim Samokhin
fuente
-2
     $date = date('Y-m-d', strtotime("+1 month"));
     echo $date;
Mohammed F. Ghazo
fuente