It was the end of the month and another date bug raised its head. In my Laravel application, I make use of the dependency Carbon, which adds syntactic sugar to help manage notoriously complex date handling.
I needed to find dates up to the end of the previous month. I combined the subMonth()
method with the endOfMonth()
method to give me the date I needed.
At least I thought so. Verbalising the problem and finding these methods, I assumed the outcome would be as I expected. It wasn't!
On the 31st of March I was seeing dates that included March. The following was giving me a date of 2022-03-31
.
Carbon::createFromFormat('Y-m-d', '2022-03-31')
->subMonth()
->endOfMonth(); // 2022-03-31
Writing a test case
I wrote a test with what I expected to happen with a range of dates to see whether I could understand the problem. I came up with the following PHPUnit test case;
<?php
namespace Tests\Unit;
use Carbon\Carbon;
use Tests\Unit\TestCase;
class DateTest extends TestCase
{
/**
* @test
* @dataProvider dates
*/
public function subMonthEndOfMonth(string $from, string $match): void
{
$dateFrom = Carbon::createFromFormat('Y-m-d', $from)
->subMonth()
->endOfMonth();
$this->assertSame($dateFrom->format('Y-m-d'), $match);
}
public function dates(): iterable
{
return [
'Start of January should be end of December previous year' => ['2022-01-01', '2021-12-31'],
'End of January should be end of December previous year' => ['2022-01-31', '2021-12-31'],
'Start of February should be end of January' => ['2022-02-01', '2022-01-31'],
'27th February should be end of January' => ['2022-02-27', '2022-01-31'],
'End of February should be end of January' => ['2022-02-28', '2022-01-31'],
'February rollover to March, means should be end of February' => ['2022-02-29', '2022-02-28'],
'Start of March should be end of February' => ['2022-03-01', '2022-02-28'],
'28th March should be end of February' => ['2022-03-28', '2022-02-28'],
'29th March should be end of February' => ['2022-03-29', '2022-02-28'], // ❌ 2022-03-31!
'End of March should be end of February' => ['2022-03-31', '2022-02-28'], // ❌ 2022-03-31!
'Start of April should be end of March' => ['2022-04-01', '2022-03-31'],
'End of April should be end of March' => ['2022-04-30', '2022-03-31'],
'Start of May should be end of April' => ['2022-05-01', '2022-04-30'],
'30th May should be end of April' => ['2022-05-30', '2022-04-30'],
'End of May should be end of April' => ['2022-05-31', '2022-04-30'], // ❌ 2022-04-30!
];
}
}
Three of the tests failed. These are marked above with a ❌ and the date which is the actual result.
Understanding the results
Breaking this down, I found out that subtracting one month from the 31st of March gives you the 31st of February. This is a non-sensical date, so the language overflows this to a date that makes sense. The date then becomes 2022-03-03
. Then I wanted the "end of the month" so I ended up with 2022-03-31
.
The same happens when you "add a month". For example, adding a month to 2022-01-30
, then calculating the end of the month gives you 2022-03-30
… First, the computer calculates "next month" giving the 30th of February, which overflows to 2022-03-02
, then it calculates the end of that month.
Carbon::createFromFormat('Y-m-d', '2022-01-30')
->addMonth()
->endOfMonth(); // 2022-03-31
Solutions
This behaviour is documented under Addition and Subtraction, where it talks about "overflow". There seems to be (at least) three different built-in ways to achieve what I needed. Here are the three solutions when using addMonth()
;
Carbon::useMonthsOverflow(false);
Carbon::createFromFormat('Y-m-d', '2022-01-31')
->addMonth(); // 2022-02-28
or
Carbon::createFromFormat('Y-m-d', '2022-01-31')
->addMonthNoOverflow(); // 2022-02-28
or
Carbon::createFromFormat('Y-m-d', '2022-01-31')
->settings([
'monthOverflow' => false,
])->addMonth(); // 2022-02-28
To mitigate the discrepencies, another solution would be to "zero" or reset the month to start with. This is the solution I have used in the application, as it's fluent, readable and doesn't require knowledge of what the overflow is doing.
// addMonth()
Carbon::createFromFormat('Y-m-d', '2022-01-30')
->startOfMonth() // 2022-01-01
->addMonth() // 2022-02-01
->endOfMonth(); // 2022-02-28
// subMonth()
Carbon::createFromFormat('Y-m-d', '2022-03-31')
->startOfMonth() // 2022-03-01
->subMonth() // 2022-02-01
->endOfMonth(); // 2022-02-28
Running each of these solutions in the test suite with the same data provider, we get 45 passing results;
/**
* @test
* @dataProvider dates
*/
public function subMonthEndOfMonthOverflow(string $from, string $match): void
{
$dateFrom = Carbon::createFromFormat('Y-m-d', $from)
->subMonthNoOverflow()
->endOfMonth();
$this->assertSame($dateFrom->format('Y-m-d'), $match);
}
/**
* @test
* @dataProvider dates
*/
public function subMonthEndOfMonthSettings(string $from, string $match): void
{
$dateFrom = Carbon::createFromFormat('Y-m-d', $from)
->settings([
'monthOverflow' => false,
])
->subMonth()
->endOfMonth();
$this->assertSame($dateFrom->format('Y-m-d'), $match);
}
/**
* @test
* @dataProvider dates
*/
public function subMonthEndOfMonthSetStart(string $from, string $match): void
{
$dateFrom = Carbon::createFromFormat('Y-m-d', $from)
->startOfMonth()
->subMonth()
->endOfMonth();
$this->assertSame($dateFrom->format('Y-m-d'), $match);
}
Thoughts
Carbon provides a lot of methods that seem to cover every eventuality. And that's brilliant. However, I think the defaults could make more sense. From a common-sense point of view, if I "subtract a month", I expect the month I get back to be one month previous. If the day is outside of the month, it might make sense that it is changed to the last possible day it could be for that month.
I am not the only developer who has fallen foul of this behaviour, there are a few issues on the GitHub repository of people discussing the same problem. Even someone providing the reasoning and different solutions.
After discussion with other developers, a suggestion of creating new nextMonth()
and previousMonth()
methods could solve this problem. Although, these methods may get lost in the weeds, as there are already a lot of methods that do variations on the same thing. The problem is already solvable in multiple ways, so would this just adding more confusion?