PHP DateTime :: modificar adição e subtração de meses

101

Tenho trabalhado muito com o DateTime class e recentemente encontrei o que pensei ser um bug ao adicionar meses. Depois de um pouco de pesquisa, parece que não era um bug, mas funcionava conforme o esperado. De acordo com a documentação encontrada aqui :

Exemplo # 2 Cuidado ao adicionar ou subtrair 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

Alguém pode justificar por que isso não é considerado um bug?

Além disso, alguém tem alguma solução elegante para corrigir o problema e fazer com que o +1 mês funcione conforme o esperado, em vez de como pretendido?

tplaner
fonte
Quanto você esperaria de "2001-01-31" mais 1 mês? ... "2001-02-28"? "2001-03-01"?
Artefacto
57
Pessoalmente, espero que seja 28/02/2001.
tplaner
Mesma história com strtotime() stackoverflow.com/questions/7119777/…
Valentin Despa
2
Sim, é uma peculiaridade irritante. Você leu as letras miúdas para descobrir que P1M é de 31 dias. Realmente não entendo por que as pessoas continuam defendendo isso como um comportamento "correto".
Indivision Dev
Parece que a opinião popular é que a lógica deve arredondar para baixo (para 2/28), embora o PHP arredonde para cima (para 3/1) ... embora eu prefira o jeito do PHP, mas o Excel da Microsoft arredonda para baixo, colocando os desenvolvedores da web contra os usuários de planilhas ...
Dave Heq

Respostas:

106

Por que não é um bug:

O comportamento atual está correto. O seguinte acontece internamente:

  1. +1 monthaumenta o número do mês (originalmente 1) em um. Isso marca o encontro 2010-02-31.

  2. O segundo mês (fevereiro) tem apenas 28 dias em 2010, então o PHP corrige isso automaticamente continuando a contar os dias a partir de 1º de fevereiro. Você então termina em 3 de março.

Como conseguir o que deseja:

Para obter o que deseja: verifique manualmente o próximo mês. Em seguida, adicione o número de dias que o próximo mês tem.

Espero que você mesmo consiga codificar isso. Estou apenas dando o que fazer.

Maneira do PHP 5.3:

Para obter o comportamento correto, você pode usar uma das novas funcionalidades do PHP 5.3 que apresenta a estrofe de tempo relativo first day of. Esta estrofe pode ser usado em combinação com next month, fifth monthou +8 monthspara ir para o primeiro dia do mês especificado. Em vez +1 monthdo que você está fazendo, você pode usar este código para obter o primeiro dia do próximo mês assim:

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

Este script será gerado corretamente February. As seguintes coisas acontecem quando o PHP processa esta first day of next monthestrofe:

  1. next monthaumenta o número do mês (originalmente 1) em um. Isso torna a data 2010-02-31.

  2. first day ofdefine o número do dia como 1, resultando na data 01-02-2010.

Shamittomar
fonte
1
Então o que você está dizendo é que literalmente adiciona 1 mês, ignorando completamente os dias? Portanto, estou assumindo que você pode ter um problema semelhante com +1 ano se adicioná-lo durante um ano bissexto.
tplaner
@evolve, sim, acrescenta literário 1 mês.
shamittomar
13
E se você subtrair 1 mês após adicioná-lo, você acaba com uma data totalmente diferente, eu presumo. Isso parece muito pouco intuitivo.
Dan Breen
2
Excelente exemplo sobre como usar as novas estrofes no PHP 5.3, onde você pode usar o primeiro dia, último dia, este mês, próximo mês e mês anterior.
Kim Stacks
6
imho, isso é um bug. um bug sério. se eu quiser adicionar 31 dias, adiciono 31 dias. Quero adicionar um mês, um mês deve ser adicionado, não 31 dias.
low_rents
12

Aqui está outra solução compacta totalmente usando métodos DateTime, modificando o objeto no local sem criar 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;

Ele produz:

2012-01-31
2012-02-29
Rudiger W.
fonte
1
Obrigado. A melhor solução fornecida aqui até agora. Você também pode encurtar o código para $dt->modify()->modify(). Funciona tão bem.
Alph.Dev
10

Isso pode ser ú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
fonte
2
Não é uma solução geral, pois só funciona para determinados insumos, como o primeiro dia do mês. Por exemplo, fazer isso para o dia 30 de janeiro leva ao sofrimento.
Jens Roland de
Ou você poderia fazer$dateTime->modify('first day of next month')->modify('-1day')
Anthony
6

Minha solução para o 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
fonte
3
O comando "clone" foi a solução para meus problemas de atribuição de variáveis. Obrigado por isso.
Steph Rose
4

Eu concordo com o sentimento do OP de que isso é contra-intuitivo e frustrante, mas também determina o que +1 month significa nos cenários em que isso ocorre. Considere estes exemplos:

Você começa com 31/01/2015 e deseja adicionar um mês 6 vezes para obter um ciclo de agendamento para envio de um boletim informativo por e-mail. Com as expectativas iniciais do OP em mente, isso retornaria:

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

De imediato, observe que esperamos +1 monthsignificarlast day of month ou, alternativamente, adicionar 1 mês por iteração, mas sempre em referência ao ponto inicial. Em vez de interpretar isso como "último dia do mês", poderíamos lê-lo como "31º dia do próximo mês ou o último disponível dentro desse mês". Isso significa que saltamos de 30 de abril para 31 de maio em vez de 30 de maio. Observe que isso não ocorre porque é o "último dia do mês", mas porque queremos "o mais próximo disponível para a data do mês de início".

Então, suponha que um de nossos usuários assine outro boletim informativo para começar em 30/01/2015. Qual é a data intuitiva para +1 month? Uma interpretação seria "30º dia do próximo mês ou o mais próximo disponível", que retornaria:

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

Isso seria bom, exceto quando nosso usuário recebesse os dois boletins no mesmo dia. Vamos supor que este é um problema do lado da oferta em vez do lado da demanda. Não estamos preocupados que o usuário fique incomodado em receber 2 newsletters no mesmo dia, mas em vez disso, nossos servidores de e-mail não podem pagar pela largura de banda para enviar duas vezes mais muitos boletins informativos. Com isso em mente, voltamos à outra interpretação de "+1 mês" como "enviar do penúltimo dia de cada mês" que retornaria:

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

Agora evitamos qualquer sobreposição com o primeiro conjunto, mas também terminamos com 29 de abril e 29 de junho, o que certamente corresponde às nossas intuições originais que +1 monthsimplesmente deveriam retornar m/$d/You o atraente e simples m/30/Ypara todos os meses possíveis. Portanto, agora vamos considerar uma terceira interpretação do +1 monthuso de ambas as datas:

31 de janeiro

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

30 de janeiro

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

A descrição acima tem alguns problemas. Fevereiro é ignorado, o que pode ser um problema tanto no fim do fornecimento (digamos, se houver uma alocação mensal de largura de banda e fevereiro for desperdiçado e março for dobrado) quanto no final da demanda (os usuários se sentem enganados em fevereiro e percebem o março extra como tentativa de corrigir o erro). Por outro lado, observe que os dois conjuntos de datas:

  • nunca se sobrepõe
  • estão sempre na mesma data quando esse mês tem a data (então o conjunto de 30 de janeiro parece bem limpo)
  • são todos dentro de 3 dias (1 dia na maioria dos casos) do que pode ser considerado a data "correta".
  • estão todos a pelo menos 28 dias (um mês lunar) de seu sucessor e predecessor, de modo muito bem distribuído.

Dados os dois últimos conjuntos, não seria difícil simplesmente reverter uma das datas se ela caísse fora do mês seguinte real (então volte para 28 de fevereiro e 30 de abril no primeiro conjunto) e não perder o sono durante o sobreposição ocasional e divergência do padrão "último dia do mês" vs "penúltimo dia do mês". Mas esperar que a biblioteca escolha entre "mais bonito / natural", "interpretação matemática de 31/02 e outros estouros do mês" e "em relação ao primeiro dia do mês ou mês passado" sempre vai acabar com as expectativas de alguém não sendo atendidas e alguma programação precisa ajustar a data "errada" para evitar o problema do mundo real que a interpretação "errada" apresenta.

Então, novamente, embora eu também espere +1 monthretornar uma data que na verdade é no mês seguinte, não é tão simples quanto a intuição e, dadas as escolhas, ir com a matemática acima das expectativas dos desenvolvedores da web é provavelmente a escolha segura.

Aqui está uma solução alternativa que ainda é tão desajeitada quanto qualquer outra, mas acho que tem bons 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);
}

Não é o ideal, mas a lógica subjacente é: Se adicionar 1 mês resultar em uma data diferente do mês seguinte esperado, descarte essa data e adicione 4 semanas. Aqui estão os resultados com as duas datas de teste:

31 de janeiro

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

30 de janeiro

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

(Meu código é uma bagunça e não funcionaria em um cenário de vários anos. Agradeço a qualquer pessoa que reescreva a solução com um código mais elegante, desde que a premissa subjacente seja mantida intacta, ou seja, se +1 mês retorna uma data funky, use +4 semanas em vez disso.)

Anthony
fonte
4

Fiz uma função que retorna um DateInterval para garantir que a adição de um mês mostre o mês seguinte e remove os dias depois disso.

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

Em conjunto com a resposta de shamittomar, poderia ser isso adicionando meses "com segurança":

/**
 * 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
fonte
Muito obrigado!!
geckos
2

Eu descobri uma maneira mais rápida de contornar isso usando o seguinte 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
fonte
1

Aqui está uma implementação de uma versão aprimorada da resposta de Juhana em uma pergunta 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;
    }
}

Essa versão assume $createdDateque você está lidando com um período mensal recorrente, como uma assinatura, que começou em uma data específica, como o dia 31. Sempre demora $createdDatemuito para que as datas "recorrentes" não mudem para valores mais baixos à medida que são empurradas para os meses de menor valor (por exemplo, todas as datas recorrentes 29, 30 ou 31 não ficarão presas no dia 28 após passar até um ano não bissexto de fevereiro).

Aqui estão alguns códigos de driver para testar o 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;
}

Quais saídas:

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
fonte
1

Esta é uma versão aprimorada da resposta de Kasihasi a uma pergunta relacionada. Isso adicionará ou subtrairá corretamente um número arbitrário de meses a uma data.

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 exemplo:

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

irá produzir:

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

Se você quiser apenas evitar pular um mês, você pode realizar algo assim para tirar a data e executar um loop no próximo mês reduzindo a data em um e verificando novamente até uma data válida onde $ started_calculated é uma string válida para strtotime (ex. mysql datetime ou "now"). Isso indica que o final do mês está entre 1 minuto e meia-noite, em vez de pular o mês.

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

Se usar strtotime()apenas use$date = strtotime('first day of +1 month');

Primoz Roma
fonte
0

Eu precisava conseguir uma data para 'este mês do ano passado' e fica desagradável rapidamente quando este mês é fevereiro em um ano bissexto. No entanto, acredito que isso funcione ...: - / O truque parece ser basear sua alteração no primeiro dia do mês.

$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);
Just Plain High
fonte
0
$ds = new DateTime();
$ds->modify('+1 month');
$ds->modify('first day of this month');
Tim Graham
fonte
1
Você precisa explicar sua resposta. Respostas apenas codificadas são consideradas de baixa qualidade
Machavity
Obrigado! Esta é a resposta mais legal até agora. Se você trocar as últimas 2 linhas, sempre dará o mês correto. Kudos!
Danny Schoemann
0
$month = 1; $year = 2017;
echo date('n', mktime(0, 0, 0, $month + 2, -1, $year));

irá imprimir 2(fevereiro). funcionará por outros meses também.

galki
fonte
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:

O método add()da classe DateTime modifica o valor do objeto para que após chamar add()em um objeto DateTime ele retorne o novo objeto de data e também modifique o próprio objeto.

MiharbKH
fonte
0

você também pode fazer isso apenas com date () e strtotime (). Por exemplo, para adicionar 1 mês à data de hoje:

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

se você deseja usar a classe datetime, tudo bem também, mas é tão fácil quanto. mais detalhes aqui

PHP Addict
fonte
0

A resposta aceita já explica porque isso não é um mas, e algumas outras respostas apresentam uma solução legal com expressões php como first day of the +2 months . O problema com essas expressões é que não são preenchidas automaticamente.

A solução é bastante simples. Primeiro, você deve encontrar abstrações úteis que refletem seu espaço de problema. Nesse caso, é um ISO8601DateTime. Em segundo lugar, deve haver várias implementações que podem trazer uma representação textual desejada. Por exemplo, Today, Tomorrow, The first day of this month, Future- todos representam uma implementação específica do ISO8601DateTimeconceito.

Portanto, no seu caso, uma implementação de que você precisa é TheFirstDayOfNMonthsLater. É fácil encontrar apenas olhando as subclasses llist em qualquer IDE. Aqui está o 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

A mesma coisa com os últimos dias de um mês. Para obter mais exemplos dessa abordagem, leia isto .

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