DEV Community

loading...

Dates and time zones in PHP

timoschinkel profile image Timo Schinkel ・4 min read

Working on an existing codebase means you can get unexpected surprises, like having dates being represented as strings inside application logic. We decided to migrate this to PHP's DateTime objects. While migrating some of these string to DateTime instances in our codebase I came across code where we create a DateTime object based on data from our database and from API responses. The time zones of our application and our website are one and the same, but some APIs tend to send their data in UTC. And our default time zone is Europe/Amsterdam. What can possibly go wrong?!

Naive

The code I found used a naive solution. Although it might look good - we add a time zone, right? - the result is not what I expected:

// somewhere in our application bootstrap
date_default_timezone_set('Europe/Amsterdam');

// somewhere deep in our application
$date = new \DateTimeImmutable::createFromFormat(
    \DateTime::ATOM,
    '2020-07-23T12:00:00+00:00'
);

// let's output
print $date->format('c');

This will print 2020-07-23T12:00:00+00:00. Nice! Now let's show this to our users. Users typically don't care for time zones, they just want to know what the date and time is for them. You might want to localize this, but for now I will use the format YYYY-MM-DD HH:MM:SS. So what will be the output of $date using this format when in Europe/Amsterdam? 2020-07-23 12:00:00. Hmmmz, that's not right. I expected this to be 2020-07-23 14:00:00, seeing that Amsterdam is in UTC + 2:00.

NB I'm using DateTimeImmutable in my examples and not DateTime. This is deliberate. As DateTime objects are mutable by default you can find yourself in unexpected situations. An example is when you pass a DateTime object to a different class or function. You don't know what this class or function will do with that object and if it decides to use add or sub your instance will be affected:

function getUpcomingOpeningHours(DateTime $start): array
{
    $database->query(
        '/* query */',
        [
            'start' => $start,
            'end' => $start->add(new DateInterval('P7D'))
        ]
    );
}


$date = new DateTime();
$openingHours = getUpcomingOpeningHours($date);

print "Upcoming opening hours since ${date->format('Y-m-d')}: ";

This will now print the date 7 days into the future. Surprise!

Specify a time zone 1

A look at the documentation of DateTime::createFromFormat shows it accepts a third parameter; an instance of DateTimeZone:

// set the default time zone
date_default_timezone_set('Europe/Amsterdam');

// create a date object
$date = new \DateTimeImmutable::createFromFormat(
    \DateTime::ATOM,
    '2020-07-23T12:00:00+00:00',
    new \DateTimeZone(date_default_timezone_get())
);

// show the user
print $date->format('Y-m-d H:i:s');

This will print 2020-07-23 12:00:00. Still wrong. But why? From the documentation:

The timezone parameter and the current timezone are ignored when the time parameter either contains a UNIX timestamp (e.g. 946684800) or specifies a timezone (e.g. 2010-01-28T15:00:00+02:00).

This means that this approach only works if the date time string you are receiving is set in a specific time zone, but does not have a time zone indication in the date time string. In my personal opinion that is an undesirable situation, but I have seen far worse:

// set the default time zone
date_default_timezone_set('Europe/Amsterdam');

// create a date object
$date = new \DateTimeImmutable::createFromFormat(
    'Y-m-d H:i:s',
    '2020-07-23 12:00:00',
    new \DateTimeZone(date_default_timezone_get())
);

// show the user
print $date->format('Y-m-d H:i:s');

This prints 2020-07-23 12:00:00; winning! But wait, this example does not make any sense. If you create a DateTime object without any time zone specification it will use the default time zone. What happens if we specify a different time zone?

// set the default time zone
date_default_timezone_set('Europe/Amsterdam');

// create a date object
$date = new \DateTimeImmutable::createFromFormat(
    'Y-m-d H:i:s',
    '2020-07-23 12:00:00',
    new \DateTimeZone('UTC')
);

// show the user
print $date->format('Y-m-d H:i:s');

This prints 2020-07-23 12:00:00. That is still not what we expect.

Specify a time zone 2

So, what to do? We need to tell the DateTime explicitly what time zone we're expecting:

// set the default time zone
date_default_timezone_set('Europe/Amsterdam');

// create a date object
$date = new \DateTimeImmutable::createFromFormat(
    \DateTime::ATOM,
    '2020-07-23T12:00:00+00:00'
)->setTimeZone(new \DateTimeZone(date_default_timezone_get()));

// show the user
print $date->format('Y-m-d H:i:s');

This will print 2020-07-23 14:00:00. Which is correct since at July 23rd 2020 Europe/Amsterdam is +02:00.

I'm using the functions date_default_timezone_set() and date_default_timezone_get(). Unless you let your users specify their time zone themselves I would recommend using these functions - or abstractions of them - in your code for this. By consistently using date_default_timezone_get() you make sure your code will not break once it gets decided your application should also become available in territories with a different time zone. You need to make sure that somewhere in the bootstrap of your application you specify the correct time zone, and your entire application should follow you in the time zone migration.

tl;dr

When you're building DateTime objects from date time strings and you want to use it to display a human readable date time format always specify the time zone of the object by using DateTime::setTimezone(). And to ensure you don't end up showing incorrect times it is always a good idea to either store or submit a timestamp, which is by definition in UTC - but can only represent dates after the unix epoch, or a date time with a time zone indication.

Discussion (0)

pic
Editor guide