PHP:CSI - Date Is Less Than One Month Ago

Working with logic surrounding dates can sometimes be difficult and it's fairly common to come across really subtle date and time based bugs.

I was recently shown a bug in a PHP application that looks like it should be working at face value, but doesn't actually produce the correct result.

The issue in question was surrounding a date comparison. A collection of objects containing dates were compared in order to find those dates that were less than a month old. This worked fine in testing since all testing dates were less than a month old. After the system was in use for a number of months it became clear that the check wasn't working so further investigation was needed.

To demonstrate what was going on without adding code from the project I will create an array of dates that will range from 2 weeks to 3 months in the past. Using the DateTime() class this is easy as we can pass in strings that get parsed into dates.

$listOfDates = [
  new DateTime('-2 weeks'),
  new DateTime('-1 month'),
  new DateTime('-2 months'),
  new DateTime('-3 months'),
];

Here is the basics of the code in question. This creates today's date and then subtracts a period of 1 month from that date in order to do the comparison to see if the passed in date was less than a month ago.

$now = new \DateTime();

foreach ($listOfDates as $date) {
  if ($date >= $now->sub(new \DateInterval('P1M'))) {
    echo $date->format("d-m-Y")  . ' is less than one month ago.' . PHP_EOL;
  }
}

This code produces the following result.

10-02-2022 is less than one month ago.
24-01-2022 is less than one month ago.
24-12-2021 is less than one month ago.
24-11-2021 is less than one month ago.

According to the check, all of the dates presented were less than one month ago. This is clearly not the case since we absolutely created dates over two months in the past.

The problem here was that although we were working out a date one month in the past, the code doing so was inside the loop. This meant that the only the first comparison was for one month, the second comparison was then two months, the third comparison three months, and so on.

The code that takes the current date and subtracts one month is working correctly. Looking at this in isolation shows that the date calculated is is correct.

$now = new \DateTime();
echo 'Before: ' . $date->format("d-m-Y") . PHP_EOL;
$now->sub(new \DateInterval('P1M'));
echo 'After: ' . $date->format("d-m-Y") . PHP_EOL;

The issue is that the although the sub() method returns the DateTime object it also updates the internal date value of the object. This means that we are changing the date stored in $now every time we run the sub() method.

What was expected was that the sub() method would return a new DateTime object and not change the internal value. In fact, looking at the documentation on php.net for the sub() method shows a few other users also making the same assumption.

The Solution

The solution was to work out the date one month ago and store this as a variable before entering the loop. We can then perform a comparison with the date.

$now = new \DateTime();

$oneMonthAgo = $now->sub(new \DateInterval('P1M'));

foreach ($listOfDates as $date) {
  if ($date >= $oneMonthAgo) {
    echo $date->format("d-m-Y")  . ' is less than one month ago.' . PHP_EOL;
    echo $now->format("d-m-Y") . PHP_EOL;
  }
}

This new code produces the following result.

10-02-2022 is less than one month ago.

This produces the correct response, with only the first date being seen as less than one month ago and the rest of the dates not being printed out.

As always, testing dates is hard, but in this case the test harness in use wasn't sufficient enough to catch this situation. When writing tests it is essential that you test for values that would be correct as well as values that are incorrect. Testing the resiliency of the code you write is just as important as testing that it works as expected.

It also shows that you need to be very clear about what the methods of objects being used are actually doing. Making assumptions on how methods and functions work can lead to mistakes so make sure you read documentation where available.

Do you have any PHP:CSI problems we can write about? Let us know! You can add a comment below or contact us.

More in this series

Comments

<?php

$now         = new \DateTime();
$listOfDates = [
    new DateTime('-2 weeks'),
    new DateTime('-1 month'),
    new DateTime('-2 months'),
    new DateTime('-3 months'),
];

$oneMonthAgo = $now->sub(new \DateInterval('P1M'));

array_walk($listOfDates, function ($val, $key, $oneMonthAgo) {
    echo $val->format('d-m-Y') . ($val > $oneMonthAgo ? ' is less than a month ago.' : '') . PHP_EOL;
}, $oneMonthAgo);

 

Permalink

The array_walk() function is also a good way of printing out the information, thanks for the hint Anon :)

Name
Philip Norton
Permalink

Add new comment

The content of this field is kept private and will not be shown publicly.
CAPTCHA
1 + 3 =
Solve this simple math problem and enter the result. E.g. for 1+3, enter 4.
This question is for testing whether or not you are a human visitor and to prevent automated spam submissions.