“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:
- Directly in the constructor
new DateTime('+50 minutes')
- 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 to03: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:
- When we use the constructor with ISO 8601 format
new DateInterval('PT100M')
, it creates a real duration, which is added to the absolute time. - When we use
createFromDateString('100 minutes')
, it creates more of a calendar interval, which behaves similarly tomodify()
– it first performs “clock” arithmetic and then deals with problems with invalid times.
So not all DateInterval
s 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?
- We converted the original time (01:30 CET) to UTC (00:30 UTC)
- We added a day in UTC (00:30 UTC the following day)
- But the following day, Prague is already on summer time (CEST), which has an offset of +2 hours from UTC
- 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:
- Use
Nette\Utils\DateTime
, which fixes the problematic behavior. - Or perform time arithmetic in the UTC zone and then convert back to the local zone.
- 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.
Leave a comment