Campaign "Contact field value" condition on a date field compares in UTC instead of the configured timezone (off-by-one near local midnight)
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_valuewith a non-dateoperator is handled inMautic\LeadBundle\EventListener\CampaignSubscriber::onCampaignTriggerCondition()→Mautic\LeadBundle\Helper\CustomFieldHelper::fieldValueTransfomer(). - For
type === 'date',fieldValueTransfomer()ends inMautic\CoreBundle\Helper\DateTimeHelper::toUtcString('Y-m-d'). toUtcString()converts the (local wall-clock) value to UTC before extractingY-m-d. Adatefield 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 inTimezoneResolver(from #7244). The UTC conversion shifts the compared date across the midnight boundary.- Because a relative value like
+ N dayskeeps 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 forEurope/Berlinin summer). - The
operator === 'date'sub-path (today / tomorrow / yesterday / anniversary) is correct: it anchors "now" indefault_timezoneand usesformat('Y-m-d')with no UTC cast (CampaignSubscriber::compareDateValue()). Only the value-comparison path is affected. TimezoneResolveris only wired intoMautic\LeadBundle\Segment\….CampaignSubscriberdoes 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(sogetDefaultTimezone()resolves to Berlin), and- php.ini
date.timezone=Europe/Berlinfor the CLI (soconsole-application.phpdoes not force UTC).
- Create a custom contact field of type date, e.g.
visit_date. - Create a campaign: a segment source → condition Contact field value → field
visit_date, operator=, value+ 4 days→ the Yes path sends an email. - Create a contact with
visit_date = <today> + 4 days. - Run
bin/console mautic:campaigns:triggerwhile the local time is within the UTC offset of midnight, e.g. 00:30Europe/Berlin. - Observe: the condition does not match the contact whose
visit_dateis +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):
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-oneThe 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'); }inapp/console-application.phpis by design (introduced in #7617) and is not part of this report — it merely requires operators to set php.inidate.timezone. This issue concerns the campaign condition convertingdatefields 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 branchMautic\LeadBundle\Helper\CustomFieldHelper::fieldValueTransfomer()—datecase →toUtcString('Y-m-d')Mautic\CoreBundle\Helper\DateTimeHelper::toUtcString()/toLocalString()Mautic\LeadBundle\Helper\FormFieldHelper::getDateChoices()— limited relative values for thedateoperatorMautic\LeadBundle\Segment\Decorator\Date\TimezoneResolver— the segment fix from #7244 (not used by campaigns)
Relevant log output
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.
Source: mautic/mautic