phptimezonedatediffdateinterval

php date_diff bug with 2021-12-01 and not UTC timezone


Make diff and then apply it to the same date, in result we want to see the same date, but result can be unpredictable.

Tested on php 8.0.16, 7.4.7

<?php
date_default_timezone_set('Europe/Amsterdam');

$dateTimeNow = new DateTimeImmutable('2022-03-18 00:00:00.000000');
$dateTimeOld =   new DateTimeImmutable('2021-12-01 00:00:00.000000');
$timeInterval = $dateTimeOld->diff($dateTimeNow);
var_dump($dateTimeNow); //output 2022-03-18
var_dump($dateTimeOld->add($timeInterval)); //output 2022-03-16 but must be 2022-03-18!

$dateTimeNow = new DateTimeImmutable('2022-03-01 00:00:00.000000');
$dateTimeOld =   new DateTimeImmutable('2021-12-01 00:00:00.000000');
$timeInterval = $dateTimeOld->diff($dateTimeNow);
var_dump($dateTimeNow); //output 2022-03-01
var_dump($dateTimeOld->add($timeInterval)); //output 2022-03-02 but must be 2022-03-01!

date_default_timezone_set('UTC');

$dateTimeNow = new DateTimeImmutable('2022-03-18 00:00:00.000000');
$dateTimeOld =   new DateTimeImmutable('2021-12-01 00:00:00.000000');
$timeInterval = $dateTimeOld->diff($dateTimeNow);
var_dump($dateTimeNow); //output 2022-03-18
var_dump($dateTimeOld->add($timeInterval)); //output 2022-03-18 correct!

$dateTimeNow = new DateTimeImmutable('2022-03-01 00:00:00.000000');
$dateTimeOld =   new DateTimeImmutable('2021-12-01 00:00:00.000000');
$timeInterval = $dateTimeOld->diff($dateTimeNow);
var_dump($dateTimeNow); //output 2022-03-01
var_dump($dateTimeOld->add($timeInterval)); //output 2022-03-01 correct!

NOTE. On php 8.1.3 works without bug

How can it be explained, and what should i use for this purpose to be sure it is valid and stable


Solution

  • So I rearranged your code to:

    $tzs = [
        new DateTimezone('Europe/Amsterdam'),
        new DateTimezone('UTC')
    ];
    
    $base = '2021-12-01 00:00:00.000000';
    
    $dates = [
        '2022-03-18 00:00:00.000000',
        '2022-03-01 00:00:00.000000',
        
    ];
    
    foreach( $tzs as $tz ) {
        $basedate = new DateTimeImmutable($base, $tz);
        foreach( $dates as $date ) {
            $curdate = new DateTimeImmutable($date, $tz);
            $diff = $basedate->diff($curdate);
            $test = $basedate->add($diff);
            
            printf("%s %s %5s/%4s %s %s\n",
                $basedate->format('c'),
                $curdate->format('c'),
                $diff->format('%mm%dd'),
                $diff->format('%ad'),
                $test->format('c'),
                ($curdate == $test) ? '1' : '0'
            );
        }
    }
    

    Which outputs in php>=8.1:

    2021-12-01T00:00:00+01:00 2022-03-18T00:00:00+01:00 3m17d/107d 2022-03-18T00:00:00+01:00 1
    2021-12-01T00:00:00+01:00 2022-03-01T00:00:00+01:00  3m0d/ 90d 2022-03-01T00:00:00+01:00 1
    2021-12-01T00:00:00+00:00 2022-03-18T00:00:00+00:00 3m17d/107d 2022-03-18T00:00:00+00:00 1
    2021-12-01T00:00:00+00:00 2022-03-01T00:00:00+00:00  3m0d/ 90d 2022-03-01T00:00:00+00:00 1
    

    and in php <8.1:

    2021-12-01T00:00:00+01:00 2022-03-18T00:00:00+01:00 3m15d/107d 2022-03-16T00:00:00+01:00 0
    2021-12-01T00:00:00+01:00 2022-03-01T00:00:00+01:00 2m29d/ 90d 2022-03-02T00:00:00+01:00 0
    2021-12-01T00:00:00+00:00 2022-03-18T00:00:00+00:00 3m17d/107d 2022-03-18T00:00:00+00:00 1
    2021-12-01T00:00:00+00:00 2022-03-01T00:00:00+00:00  3m0d/ 90d 2022-03-01T00:00:00+00:00 1
    

    Which appears to differ on how either DateTime->diff() or DateInterval define 'a month'. I would wager that that difference has something to do with the timezone data and/or DST transition, hence the difference between UTC and Amesterdam TZs.

    My go-to solution here is always going to be "always store and compute dates in UTC, all other timezones are simply for human eyeballs".

    But if that is not a feasible ask, then for this specific situation where you're not dealing with anything more granular than a number of days, you can probably get away with:

    $diff = $old->diff($new);
    $diff = new DateInterval($diff->format('P%aD'));
    

    Which should strip out the nebulous "months and remainder days" and leave only the "total days", which gives consistent results across all current PHP versions. But if, in your actual code, there is a time component then you're going to have to finagle that format call to produce the correct interval spec.

    There is a laundry list of Date bugfixes in the 8.1 release but I couldn't tell you which of these is applicable.