Skip to content

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
set field_date = "2026-07-01T14:00:00"        -> stored "2026-07-01T14:00:00"
set field_date = "2026-07-01T14:00:00-07:00"  -> stored "2026-07-01T14:00:00-07:00"
set field_date = "2026-07-01T14:00:00Z"       -> stored "2026-07-01T14:00:00Z"

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("2026-07-01 14:00:00")       -> timestamp = 2026-07-01T21:00:00 UTC  (correct)
timetodate(timestamp, 'Y-m-d\TH:i:s')  -> "2026-07-01T14:00:00"                (local, wrong for storage)
correct storage value                  -> "2026-07-01T21:00:00"

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 month through DateTime::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
timeoffset(UTC)                  shift      0s -> 2026-07-01T14:00:00  (no change)
timeoffset(America/Los_Angeles)  shift -25200s -> 2026-07-01T07:00:00  (worse)

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
strtotime("2026-07-01 14:00:00")      -> 2026-07-01T21:00:00 UTC
strtotime("2026-07-01 14:00:00 UTC")  -> 2026-07-01T14:00:00 UTC

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
July  (PDT, UTC-7): timeoffset(Etc/GMT-7) -> 2026-07-01T21:00:00  correct
July  (PDT, UTC-7): timeoffset(Etc/GMT-8) -> 2026-07-01T22:00:00  wrong
January (PST, UTC-8) with the same Etc/GMT-7 setting
                                          -> 2026-01-15T21:00:00  wrong, should be 22:00

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.