PHP has a problem crossing DST barriers. This is both a feature and a bug. A feature in that some developers expect to do strict time & date mathematics. It’s a bug in that most people will expect crossing a DST barrier should alter the time accordingly. PHP does both and neither - depending on the library you use.
What Is DST?
DST, or Daylight Saving Time (it’s Saving - no “s”) is the practice of setting the clock forward and backward at certain times of the year. Meant to keep waking hours during daylight and later to save energy (recent studies dispute this).
In the United States in 2022, DST starts on March 13 at 2:00 am and ends on November 6 at 2:00 am. I used this for later examples.
How DOES PHP Handle DST Barriers?
DateTime
objects advanced to a time that crosses a DST barrier will only advance the hour if the date lands within the hour the transition should occur. That’s right, return a modified date on the exact hour that DST occurs, and it will transition the time. End at any other hour, day, or month after the barrier and it will not change the hour.
Let's look at a few examples of this. We’ll start with a very simple example that shows the hour being advanced to the 2:00 am hour where the DST barrier exists:
$dst = (new DateTimeImmutable(
'Mar 13, 2022 00:00:00',
new DateTimeZone('America/Los_Angeles')
))->modify('+2 hour');
// 2022-03-13 03:00:00.000000
You can see that, on most other days, this output would have ended at 2:00 am. Instead, it advances to 3:00 am because 2:00 am on March 13 is where the DST barrier is.
What about adding a few more minutes?
$dst = (new DateTimeImmutable(
'Mar 13, 2022 00:00:00',
new DateTimeZone('America/Los_Angeles')
))->modify('+2 hour + 10 minute');
// 2022-03-13 03:10:00.000000
That too advanced the hour the way you would expect, having crossed a DST barrier. What if we add another hour via minutes?
$dst = (new DateTimeImmutable(
'Mar 13, 2022 00:00:00',
new DateTimeZone('America/Los_Angeles')
))->modify('+2 hour + 60 minute');
// 2022-03-13 03:00:00.000000
Interesting. The hour is now just 3:00 am, as if DST never happened. The same thing happens if you use ‘+3 hour’
rather than adding ‘60 minute’
. What if we add a day? You would expect March 13 at 12:00 am to advance to March 14 at 1:00 am. That’s what happens, right?
$dst = (new DateTimeImmutable(
'Mar 13, 2022 00:00:00',
new DateTimeZone('America/Los_Angeles')
))->modify('+1 day');
// 2022-03-14 00:00:00.000000
Nope. It keeps the hour at midnight and just added a day on. In fact, if you transition to any point after the transition occurs that does not land within the exact transition hour, the hour will not advance.
The DateTime
functions use the timelib
library. What about using the libicu
library through Intl?
$cal = new IntlGregorianCalendar(2022, 2, 12, 2, 00, 00);
echo IntlDateFormatter::formatObject($cal), "\n";
$cal->add(IntlCalendar::FIELD_DAY_OF_MONTH, 1);
echo IntlDateFormatter::formatObject($cal), "\n";
$cal->add(IntlCalendar::FIELD_SECOND, 1);
echo IntlDateFormatter::formatObject($cal), "\n";
// Mar 12, 2022, 2:00:00 AM
// Mar 13, 2022, 3:00:00 AM
// Mar 13, 2022, 3:00:01 AM
Just like with the DateTime
object, landing on the exact hour of transition advances the hour. What about skipping to the next month?
$cal = new IntlGregorianCalendar(2022, 2, 12, 2, 00, 00);
echo IntlDateFormatter::formatObject($cal), "\n";
$cal->add(IntlCalendar::FIELD_MONTH, 1);
echo IntlDateFormatter::formatObject($cal), "\n";
// Mar 12, 2022, 2:00:00 AM
// Apr 13, 2022, 2:00:00 AM
The DST hour advancement does not happen.
How Do You Ensure DST Barriers Are Respected?
Others discovered that altering the time zone to one that does not have DST barriers prior to doing the modification then switch the time zone back. GMT is a universal one that can be used. This will cause the DST barriers to be respected the way most would expect. Don’t expect the library to do it for you.
How is this done? Let’s look at a DateTime
example first:
$dst = new DateTimeImmutable(
'Mar 13, 2022 00:00:00',
new DateTimeZone('America/Los_Angeles')
);
$prevTz = $dst->getTimezone();
$dst = $dst->setTimezone(new DateTimeZone('GMT'));
$dst = $dst->modify('+1 day');
$dst = $dst->setTimeZone($prevTz);
// 2022-03-14 01:00:00.000000
Finally! The output I expected. DST is respected when the time zone is set back to Los Angeles (Pacific). I only set the $dst
on most lines because I used DateTimeImmutable
, which creates new objects for each modification.
How do you do this in the Intl
library?
date_default_timezone_set('America/Los_Angeles');
$cal = new IntlGregorianCalendar(2022, 2, 12, 1, 00, 00);
echo IntlDateFormatter::formatObject($cal), "\n";
$cal->setTimeZone(new DateTimeZone('GMT'));
$cal->add(IntlCalendar::FIELD_MONTH, 1);
$cal->setTimeZone(new DateTimeZone(date_default_timezone_get()));
echo IntlDateFormatter::formatObject($cal), "\n";
// Mar 12, 2022, 1:00:00 AM
// Apr 12, 2022, 2:00:01 AM
I recommend setting the default time zone prior to creating the object. Why? The IntlGregorianCalendar
object accepts a time zone… in place of a year.