Writing date and time values in the correct time zone¶
A model that writes a date into a content entity field deals with two different notions of time: the wall clock a person reads, and the value Drupal stores in the database. The two are not the same, and nothing in the chain complains when they get confused. A model can therefore look correct, behave correctly on one site, and store values that are hours out on another.
This guide explains the storage contract for date fields, why the common combination of two tamper actions undoes its own conversion, and what to do about it today.
The storage contract¶
Drupal's datetime field type stores its value as a string, always in UTC, and
without a trailing offset or Z suffix. Two constants on
DateTimeItemInterface define the contract:
| Constant | Value |
|---|---|
DateTimeItemInterface::DATETIME_STORAGE_FORMAT |
Y-m-d\TH:i:s |
DateTimeItemInterface::STORAGE_TIMEZONE |
UTC |
A value that means "1 July 2026, 2 pm in Los Angeles" must therefore reach the
field as 2026-07-01T21:00:00. Converting the wall clock to UTC is the model's
job. The field does not do it.
A date field stores whatever it is given¶
Writing a date field with Entity: set field value stores the string verbatim. Three probes on a live site, with the value written and then read back:
1 2 3 | |
No normalization, no error
Nothing normalizes the value, nothing strips an offset suffix, and nothing
reports a problem. Feeding a token such as [current-date:html_datetime],
which carries an offset suffix, straight into a date field writes corrupt
data that nothing complains about until the value renders wrong.
The effective time zone follows the acting user¶
PHP's default time zone is not fixed for a site. On a site where system.date
has timezone.user.configurable set to true, switching to a user whose
timezone is America/Los_Angeles changes PHP's process default from UTC to
America/Los_Angeles.
This is what makes the whole problem context-dependent. Any conversion that relies on PHP's default time zone gives a different answer depending on what triggered the model, so one model produces three different results for an anonymous request, a cron run, and a logged-in user in another zone.
The round trip that undoes itself¶
Two tamper actions are the usual tools for turning a date string into a stored value:
- Tamper: String to Unix Timestamp
- Calls PHP's
strtotime(). A string that carries no explicit time zone is interpreted in the current default time zone. The tamper plugin's own source documentation warns about exactly this. - Tamper: Unix timestamp to Date
- Calls PHP's
date(), which formats the timestamp in the current default time zone.
Used as a pair, the two conversions cancel each other out. Measured with the
default time zone at America/Los_Angeles:
1 2 3 | |
strtotime() converts the wall clock to UTC correctly, and date() converts it
straight back. What lands in the field is the local wall clock labeled as UTC,
wrong by exactly the UTC offset.
Why this hits some sites and not others
When the effective time zone is already UTC — an anonymous request, a cron
run, or a site with configurable user time zones turned off — the chain is
already correct, because neither conversion shifts anything. The same model
starts storing wrong values as soon as it runs for a user in another zone.
What the two offset actions really do¶
Both actions ship in the released drupal/tamper 1.0.0, so no patch is needed
any more. The older advice to patch the tamper module through drupal.org issue
3268276 is obsolete.
- Tamper: Timezone offset
- Takes a timestamp, computes the target zone's UTC offset at that instant, so it is correct across daylight saving changes, and adds it. It is a display aid for rendering a timestamp as another zone's wall clock. It cannot subtract the request's own offset, which is what storage needs.
- Tamper: Date offset
- Applies a fixed relative string such as
+1 monththroughDateTime::modify().
Adding the offset action to the broken chain above does not repair it. Same
setup, default time zone at America/Los_Angeles:
1 2 | |
What to do today¶
The three options below are in order of preference.
1. Keep the storage step out of a user time zone context¶
If the work runs as cron, as a queue worker, or on an anonymous request, the
effective time zone is the site default. When that default is UTC, the
strtotime plus timetodate chain is already correct and needs no
compensation.
2. Make the input unambiguous¶
Give Tamper: String to Unix Timestamp
a string that carries its own time zone, for example a trailing UTC or an
explicit offset. Measured with the default time zone at America/Los_Angeles:
1 2 | |
This fixes the parsing half of the round trip. It does not fix the formatting
half, because date() still renders in the current default time zone.
3. Compensate with the offset action¶
Because date() renders timestamp + local_offset, you can pre-subtract that
shift by adding the mirror offset with a fixed Etc/GMT±N zone. Measured with
the default time zone at America/Los_Angeles:
1 2 3 4 | |
A workaround only, and it breaks across a daylight saving boundary
The compensation is a fixed number of hours, while the offset it has to cancel changes twice a year. The last line above is the same setting that is correct in July, storing a value one hour out in January, with nothing to report it. Use this only where the first two options are impossible.
The sign in Etc/GMT zone names is inverted
Etc/GMT-7 means UTC +7, not UTC-7. The inversion comes from POSIX and
is not a typo in this page.
The real fix¶
There is currently no way to make Tamper: Unix timestamp to Date emit UTC directly. It formats in whatever PHP's default time zone happens to be at that moment, and offers no way to override it.
The real fix belongs in the tamper
module, which supplies the plugin that ECA wraps: a time zone setting on
TimeToDate, defaulting to the current behavior so that existing configurations
keep working. With it, writing a storage value becomes a single action set to
UTC, and every workaround on this page becomes unnecessary. This page will be
updated with the issue reference once that issue exists.
Related resources¶
- Tamper: String to Unix Timestamp — action reference
- Tamper: Unix timestamp to Date — action reference
- Tamper: Timezone offset — action reference
- Tamper: Date offset — action reference
- Entity: set field value — action reference