#17319·mautic

Campaign "Contact field value" condition on a date field compares in UTC instead of the configured timezone (off-by-one near local midnight)

Author: dsp76Created Sep 9, 2026Updated Sep 10, 2026
LabelsT2bugcampaignsmautic-5mautic-6mautic-7

Mautic Series

7.1.x series

Mautic installed version

7.1.3

Way of installing

I installed with composer using https://github.com/mautic/recommended-project

PHP version

8.4

What browsers are you seeing the problem on?

Not relevant

What happened?

A campaign condition of type Contact field value on a date custom field (field type date), using a value comparison operator (=, !=, gte, lte, …) with a relative value such as + N days, is evaluated in UTC rather than in the instance's configured default_timezone.

On any non-UTC instance, when campaigns:trigger runs during the window between local midnight and (local midnight + UTC offset), the value the condition compares against is shifted one day back. The condition then matches the wrong cohort of contacts (or takes the wrong yes/no branch), e.g. an email intended for "N days before a date" is sent to contacts who are actually N-1 days before it.

The equivalent bug for segments was fixed in #7244 (2019) by introducing TimezoneResolver and switching the segment date decorators from toUtcString() to toLocalString(). The campaign condition path never received this fix and still converts date-field values to UTC.

Why this matters / why it's easy to hit

The dedicated "date" operator (which does handle the timezone correctly) only offers a fixed set of values via FormFieldHelper::getDateChoices():

  • anniversary, +P0D (today), -P1D (yesterday), +P1D (tomorrow), plus a static date picker.

There is no free +/- N days input for the date operator. So any multi-day offset ("4 / 8 / 12 days before a date") can only be expressed with a value-comparison operator (= and value + N days) — which is exactly the code path that converts to UTC. Users implementing "send X days before " reminders are therefore forced onto the buggy path.

Root cause (code)

  • Condition lead.field_value with a non-date operator is handled in Mautic\LeadBundle\EventListener\CampaignSubscriber::onCampaignTriggerCondition()Mautic\LeadBundle\Helper\CustomFieldHelper::fieldValueTransfomer().
  • For type === 'date', fieldValueTransfomer() ends in Mautic\CoreBundle\Helper\DateTimeHelper::toUtcString('Y-m-d').
  • toUtcString() converts the (local wall-clock) value to UTC before extracting Y-m-d. A date field is stored as a local calendar date with no time component and therefore cannot be meaningfully converted to UTC — this is the exact rationale documented in TimezoneResolver (from #7244). The UTC conversion shifts the compared date across the midnight boundary.
  • Because a relative value like + N days keeps the current time of day, the shift occurs whenever the trigger runs while the local time of day is within the UTC offset of midnight (e.g. 00:00–01:59 for Europe/Berlin in summer).
  • The operator === 'date' sub-path (today / tomorrow / yesterday / anniversary) is correct: it anchors "now" in default_timezone and uses format('Y-m-d') with no UTC cast (CampaignSubscriber::compareDateValue()). Only the value-comparison path is affected.
  • TimezoneResolver is only wired into Mautic\LeadBundle\Segment\…. CampaignSubscriber does not use it.

How can we reproduce this issue?

Prerequisite: the instance must be configured for a non-UTC timezone correctly, i.e. both:

  • default_timezone = Europe/Berlin (so getDefaultTimezone() resolves to Berlin), and
  • php.ini date.timezone = Europe/Berlin for the CLI (so console-application.php does not force UTC).
  1. Create a custom contact field of type date, e.g. visit_date.
  2. Create a campaign: a segment source → condition Contact field value → field visit_date, operator =, value + 4 days → the Yes path sends an email.
  3. Create a contact with visit_date = <today> + 4 days.
  4. Run bin/console mautic:campaigns:trigger while the local time is within the UTC offset of midnight, e.g. 00:30 Europe/Berlin.
  5. Observe: the condition does not match the contact whose visit_date is +4 days; instead contacts at +3 days are matched (off-by-one).

Deterministic isolation of the faulty conversion (no need to wait for midnight)

This exercises the real DateTimeHelper with the instance timezone correctly set to Berlin, using a value that carries a 00:30 local time (as + N days would at a 00:30 cron run):

bash
php -d date.timezone=Europe/Berlin -r '
require __DIR__."/vendor/autoload.php";
$projectDir = dirname((new ReflectionClass("AppKernel"))->getFileName(), 2);
$app = include $projectDir."/app/console-application.php";
$app->getKernel()->boot();
$d = new \Mautic\CoreBundle\Helper\DateTimeHelper("2026-09-25 00:30:00", "Y-m-d H:i:s", "Europe/Berlin");
echo "toLocalString (segment behaviour, #7244): ".$d->toLocalString("Y-m-d")."\n";
echo "toUtcString   (campaign condition, now):  ".$d->toUtcString("Y-m-d")."\n";
';

Result (instance configured for Europe/Berlin, PHP date.timezone=Europe/Berlin):

toLocalString (segment behaviour, #7244): 2026-09-25   <- correct (matches the stored local date)
toUtcString   (campaign condition, now):  2026-09-24   <- off-by-one

The UTC conversion returns the previous day even though the timezone is configured correctly, confirming the defect is in the conversion path itself and not a misconfiguration.

Expected behavior

For a date field, the condition compares against the date in the instance's default_timezone (local) — i.e. toLocalString('Y-m-d') — matching the stored local calendar date, consistent with segment behaviour after #7244.

Actual behavior

The condition compares against the UTC-shifted date (toUtcString('Y-m-d')). When the trigger runs in the local-midnight window, the comparison date is one day early, so the wrong contacts are matched / the wrong branch is taken.

Suggested fix

Mirror #7244 for the campaign condition path: for date (not datetime) fields, produce the comparison value in the local timezone instead of UTC — i.e. use toLocalString('Y-m-d') (or anchor via TimezoneResolver / default_timezone) in CustomFieldHelper::fieldValueTransfomer() for type === 'date', while leaving datetime fields on UTC. A regression test analogous to the segment date-decorator tests should be added.

Additional context

  • The console UTC guard if (empty(ini_get('date.timezone'))) { date_default_timezone_set('UTC'); } in app/console-application.php is by design (introduced in #7617) and is not part of this report — it merely requires operators to set php.ini date.timezone. This issue concerns the campaign condition converting date fields to UTC even when the timezone is configured correctly.
  • Related prior art: #7244 (segment fix, the pattern to follow), #7033 (segment date filter TZ, one-day-early), #6979 (date operator with time component).

References (files / methods)

  • Mautic\LeadBundle\EventListener\CampaignSubscriber::onCampaignTriggerCondition() — value-comparison branch
  • Mautic\LeadBundle\Helper\CustomFieldHelper::fieldValueTransfomer()date case → toUtcString('Y-m-d')
  • Mautic\CoreBundle\Helper\DateTimeHelper::toUtcString() / toLocalString()
  • Mautic\LeadBundle\Helper\FormFieldHelper::getDateChoices() — limited relative values for the date operator
  • Mautic\LeadBundle\Segment\Decorator\Date\TimezoneResolver — the segment fix from #7244 (not used by campaigns)

Relevant log output

bash

Code of Conduct

  • I confirm that I have read and agree to follow this project's Code of Conduct



Care about this issue? Want to get it resolved sooner? If you are a member of Mautic, you can add some funds to the Bounties Project so that the person who completes this task can claim those funds once it is merged by a member of the core team! Read the docs here.