100 minutes is less than 50? PHP paradoxes during time changes

“When shall we meet?” – “Tomorrow at three.” “When is that meeting?” – “Next month.” For everyday life, such time specifications are perfectly adequate. But try the same in programming and you'll quickly discover you've entered a labyrinth full of traps and unexpected surprises.

Time in programming is like a beast that appears tame until you step on its tail. And one of this beast's most powerful tricks is daylight saving time with its insidious transitions. A system supposedly created to save candles now causes programmers sleepless nights (probably around 2:30 AM when they suddenly realize their servers are doing weird things).

Let's explore the dark corners of daylight saving time transitions, how PHP (mis)handles them, and how I attempted to fix this madness in Nette Utils. Prepare for moments when 1 + 1 ≠ 2 and when adding a longer time paradoxically returns an earlier hour. Not even Einstein could have conceived of this.

First, let's run through some terminology

Before diving into the problem, let's explain a few key concepts:

  • UTC (Coordinated Universal Time) – the fundamental time standard from which all other time zones are derived. It's essentially the “zero point” for measuring time across the world.
  • Time offset – how many hours need to be added to or subtracted from UTC to obtain local time. It's denoted as UTC+X or UTC-X.
  • CET (Central European Time) – the Central European Time we use in winter. It has an offset of UTC+1, which means that when it's noon in UTC, it's 1:00 PM here.
  • CEST (Central European Summer Time) – the Central European Summer Time we use in summer. It has an offset of UTC+2, so when it's noon in UTC, it's 2:00 PM here.
  • Daylight Saving Time – a system where during a certain part of the year (usually summer) we move the clocks forward by one hour to better utilize daylight.

That moment lasted a whole light-year

Let's break down, second by second, how the transition to daylight saving time and back works. As an example, let's take the recent time change in the Czech Republic on Sunday, March 30, 2025:

  • At 01:59:58 Central European Time (CET), everything is normal.
  • At 01:59:59 CET, everything is still normal.
  • In the next second, 02:00:00 CET does NOT occur. Instead, the clocks magically jump forward.
  • This is followed by 03:00:00 Central European Summer Time (CEST).

The entire hour between 02:00:00 and 02:59:59 locally “doesn't exist” on this day. If you were supposed to have an important phone call at 2:30 AM, you're out of luck.

Similarly, during the transition back to standard time (sometimes called “winter time”) in autumn (e.g., October 26, 2025), the opposite situation occurs:

  • At 02:59:59 summer time (CEST), everything is normal.
  • In the next second, 03:00:00 CEST does NOT occur. The clocks go back.
  • This is followed by 02:00:00 standard Central European Time (CET).

In this case, the hour between 02:00:00 and 02:59:59 occurs twice. First in summer time (CEST) and then in standard time (CET). How do we distinguish which 2:30 we mean? By using the time zone designation (CET/CEST), the UTC offset (+01:00 / +02:00), or simply by referring to “summer” and “winter” time.

Time zones: What does Europe/Prague actually denote?

When we use a time zone identifier like Europe/Prague in PHP (or elsewhere), it's not just information about the current offset from UTC. It's a reference to a record in the IANA Time Zone Database (abbreviated as tz database), which contains comprehensive historical and future rules for a given geographical area:

  • Standard UTC offset (how many hours are added to or subtracted from UTC).
  • Daylight saving time rules (when it starts, when it ends).
  • Historical changes in offsets or daylight saving time rules (which can change by government decisions).

There are hundreds of such zones (America/New_York, Asia/Tokyo, Australia/Sydney). Some areas don't use daylight saving time at all (e.g., most of Africa and Asia, or regions near the equator) and have the same UTC offset year-round (e.g., Etc/UTC or Africa/Nairobi).

Absolute time: UTC and Timestamp

To avoid confusion with local times and daylight saving time, there are absolute time references:

  • UTC: As we've already mentioned, it's the basic time standard that doesn't use daylight saving time. All local times are defined as offsets from UTC. UTC is essentially “pure” time, to which each time zone then adds its offset.
  • Unix Timestamp: The number of seconds that have elapsed since the beginning of the Unix epoch (January 1, 1970, 00:00:00 UTC), not counting leap seconds. The timestamp is also absolute and independent of time zone or daylight saving time.

It's precisely the conversion between absolute time (UTC/timestamp) and local time in a specific zone where daylight saving time rules come into play.

PHP DateTime: When the clocks turn

When you work with a DateTime or DateTimeImmutable object in PHP, it always has an assigned time zone. If you don't explicitly specify one, the default zone set in PHP is used (via configuration or using date_default_timezone_set()).

What happens when you try to create a time that doesn't exist due to daylight saving time, or a time that exists twice?

Non-existent time (spring jump):

// Attempt to create a time in the "gap" on March 30, 2025
$dt = new DateTime('2025-03-30 02:30:00', new DateTimeZone('Europe/Prague'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 03:30:00 CEST (+02:00)

PHP typically “normalizes” this invalid time by moving it forward by one hour to the first valid time after the jump. So 02:30 becomes 03:30.

Ambiguous time (fall back):

// Attempt to create a time in the "overlap" on October 26, 2025
$dt = new DateTime('2025-10-26 02:30:00', new DateTimeZone('Europe/Prague'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-10-26 02:30:00 CET (+01:00)

Here, PHP by default chooses the second occurrence of that time. Why the second and not the first? Because PHP considers standard time (CET) to be the default, basic state and daylight saving time (CEST) only as a temporary adjustment. From the system's perspective, daylight saving time is just a temporary deviation from the “normal” state, and therefore when ambiguous, it prefers standard time.

Relative time expressions and their subtleties

Now we come to the really tricky part. PHP allows working with relative time expressions – strings like +30 minutes, -1 hour, or 1 day 2 hours. We can use these expressions in two ways:

  1. Directly in the constructor new DateTime('+50 minutes')
  2. In the $date->modify('+50 minutes') method

By the way, Nette has always promoted these relative time expressions because they are intuitive and clear. You definitely know them from the “expiration” configuration for sessions or in other parts of the framework.

Intuitively, we would expect that when we add a longer time period, the resulting time would be later. But with relative time expressions during the spring transition, this might not hold true! And this problem manifests itself both when used in the DateTime constructor and in the modify() method.

Imagine it's half past one in the morning, just before the spring jump. At that moment, something very bizarre might be happening in your application, which most of us won't notice because we're either peacefully sleeping in bed or even more contentedly sharing wisdom at the pub. But in server rooms around the world, the code quietly continues to run…

// for all subsequent examples we'll set the default time zone
date_default_timezone_set('Europe/Prague');

// It's now 2025-03-30 01:30:00 and we create a DateTime with a relative time of +50 minutes
$dt50 = new DateTime('+50 minutes');
echo $dt50->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 03:20:00 CEST (+02:00)

“Wait a minute, 1:30 plus 50 minutes is 2:20. Why does it show 3:20?” As we've already discussed, the hour between 2:00 and 3:00 doesn't exist. So 2:20 is an invalid time, which PHP corrects by moving it forward by an hour. Thus to 3:20 in summer time.

And what if we add a longer interval to the same starting time – say 100 minutes?

$dt100 = new DateTime('+100 minutes');
echo $dt100->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 03:10:00 CEST (+02:00)

See that! Yes, you're reading correctly. After adding 100 minutes, we got a time (03:10) that is earlier than the time after adding 50 minutes (03:20).

  • +50 minutes: 01:30 + 50 min = 02:20. This time doesn't exist because it's in that “lost hour”. PHP normalizes it by moving it forward by an hour to 03:20 CEST.
  • +100 minutes: 01:30 + 100 min (1h 40m) = 03:10. This time already exists. So PHP uses it as is.

The modify() method and constructor with relative strings in PHP tend to perform arithmetic first at the level of “clock time” and only then deal with invalid times caused by the daylight saving time jump. The result is completely unintuitive behavior that most time libraries in other languages don't exhibit. They typically interpret +X minutes as adding an exact duration (X * 60 seconds) to the absolute time moment.

DateInterval: Another layer of complications

The story gets even more complicated with the DateInterval class. It was created specifically for working with time intervals and could offer a solution to our problem. But alas…

To create a DateInterval instance, you must use the ISO 8601 format. Honestly, would you understand at first glance what PT100M means? No? Me neither. It's “Period of Time, 100 Minutes”. Standardized, but definitely not intuitive at first sight.

Nevertheless, if we swallow this strange notation, suddenly everything works correctly!

$dt = new DateTime('2025-03-30 01:30:00');
$dt->add(new DateInterval('PT100M')); // 100 Minutes - that wonderful ISO 8601 format
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 04:10:00 CEST (+02:00) - hurray, works correctly!

Great! Here it really behaves as we would expect – it adds exactly 100 minutes to the absolute time. This could be our solution… but what about that strange format?

PHP developers were aware that PT100M isn't exactly user-friendly, so they added the DateInterval::createFromDateString() method, which understands those nice text expressions like 100 minutes:

$dt = new DateTime('2025-03-30 01:30:00');
$dt->add(DateInterval::createFromDateString('100 minutes'));
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 03:10:00 CEST (+02:00) - ouch, wrong again!

And we're back where we started! The same problem as with modify(). What's happening?

In reality, we're dealing with a kind of “dual nature” of the DateInterval class. It depends on how we create it:

  1. When we use the constructor with ISO 8601 format new DateInterval('PT100M'), it creates a real duration, which is added to the absolute time.
  2. When we use createFromDateString('100 minutes'), it creates more of a calendar interval, which behaves similarly to modify() – it first performs “clock” arithmetic and then deals with problems with invalid times.

So not all DateIntervals are created equal. It's a completely different face of the same named object depending on how we create it.

One solution: Escape to UTC

One way to avoid these problems is to perform all time arithmetic in UTC, where no daylight saving time exists, and only convert the final result to the desired local zone:

$dt = new DateTime('2025-03-30 01:30:00');
$dt->setTimezone(new DateTimeZone('UTC')); // Convert to UTC
$dt->modify('+100 minutes');               // Perform operation in UTC
$dt->setTimezone(new DateTimeZone('Europe/Prague')); // Convert back
echo $dt->format('Y-m-d H:i:s T (P)');
// Correct output: 2025-03-30 04:10:00 CEST (+02:00)

Hurray! Or not? This trick can be counterproductive when adding whole days or other calendar units. First, let's verify that when we add 1 day to a time before the spring jump, we get the expected result:

$dt = new DateTime('2025-03-30 01:30:00'); // Before the jump (CET +01:00)
$dt->modify('+1 day');
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-31 01:30:00 CEST (+02:00)

We see that when adding one day, the same “clock” value (01:30) remains, but the time zone changes from CET to CEST.

But what happens when we use our UTC trick?

$dt = new DateTime('2025-03-30 01:30:00'); // Before the jump (CET +01:00)
$dt->setTimezone(new DateTimeZone('UTC')); // Converts to UTC
$dt->modify('+1 day');
$dt->setTimezone(new DateTimeZone('Europe/Prague')); // Converts back to local zone
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-31 02:30:00 CEST (+02:00) - one hour more!

Oops! The hour shifted from 1:30 to 2:30. Why?

  1. We converted the original time (01:30 CET) to UTC (00:30 UTC)
  2. We added a day in UTC (00:30 UTC the following day)
  3. But the following day, Prague is already on summer time (CEST), which has an offset of +2 hours from UTC
  4. So when we convert 00:30 UTC back, we get 02:30 CEST

This “escape to UTC” can therefore cause calendar operations to not behave intuitively from a local time perspective. So what is the correct behavior? It depends on your needs – sometimes you want to preserve the absolute time interval (like 24 hours), other times you want to preserve the calendar meaning (like “same time next day”).

Solution in Nette Utils

Because working with time, time zones, and daylight saving time is notoriously complex, I decided to add a fix for PHP's problematic behavior to Nette Utils. Specifically to the Nette\Utils\DateTime class, fixing both the constructor and the modify() method. I'm just hesitant about whether it's a BC break – I'll come back to that in the conclusion.

$dt = new Nette\Utils\DateTime('2025-03-30 01:30:00');
$dt->modify('+100 minutes');
echo $dt->format('Y-m-d H:i:s T (P)');
// Output: 2025-03-30 04:10:00 CEST (+02:00) - CORRECT!

With Nette\Utils\DateTime, the result for +100 minutes is always later than for +50 minutes, even at half past one in the morning!

When is 1 + 1 ≠ 2? When working with time!

The implementation in Nette Utils also solves more complex cases where we combine adding days and hours. Here we come to a really interesting problem: there are two possible interpretations of a relative expression like “+1 day +1 hour”. And these two interpretations give different results during the transition to daylight saving time! Let's demonstrate with an example:

First interpretation:

$dt = new DateTime('2025-03-30 01:30:00'); // CET

$dt1 = clone $dt;
$dt1->modify('+1 day'); // First add a day: 2025-03-31 01:30:00 CEST
$dt1->modify('+1 hour'); // Then add an hour: 2025-03-31 02:30:00 CEST

Second interpretation:

$dt2 = clone $dt;
$dt2->modify('+1 hour'); // First add an hour: 2025-03-30 03:30:00 CEST
$dt2->modify('+1 day');         // Then add a day: 2025-03-31 03:30:00 CEST

The difference is a whole hour! As you can see, the order of operations plays a crucial role here.

In Nette\Utils\DateTime, I chose the first interpretation as the default behavior because it's more intuitive. When we want to add “1 day and 1 hour”, we usually mean “same time next day plus an hour”. And what's best? It doesn't matter in which order you write the units. Whether you use +1 day +1 hour or +1 hour +1 day, the result will always be the same.

This consistency makes working with time expressions much more predictable and safe.

Time is hard, don't struggle alone

Working with time in PHP can be treacherous, especially around daylight saving time transitions. Relative time expressions and even some ways of using DateInterval can lead to unintuitive results.

If you need reliable time manipulation:

  1. Use Nette\Utils\DateTime, which fixes the problematic behavior.
  2. Or perform time arithmetic in the UTC zone and then convert back to the local zone.
  3. Always test the behavior of your code during daylight saving time transitions.

Now I'm just wondering if fixing the DateTime behavior in Nette Utils will be a BC break. Honestly, I don't think anyone consciously relies on the treacherous current behavior during daylight saving time transitions. So I would probably include it in Nette Utils 4.1.

Time is a difficult topic in all programming languages, not just PHP. Cache invalidation doesn't hold a candle to it.

about a month ago in section PHP | blog written by David Grudl | back to top

You might be interested in

Leave a comment

Text of the comment
Contact

(kvůli gravataru)



*kurzíva* **tučné** "odkaz":http://example.com /--php phpkod(); \--

phpFashion © 2004, 2025 David Grudl | o blogu

Ukázky zdrojových kódů smíte používat s uvedením autora a URL tohoto webu bez dalších omezení.