framework #61894: [13.x] Fix `schedule:list` timezone conversion when the next runs straddle a DST change
Contributed by xiCO2k
Part of the framework v13.35.0 release
Laravel 13.x ships a targeted fix for schedule:list's timezone conversion (PR #61894, a follow-up to #60877). When a scheduled event's next two runs fall on opposite sides of a daylight saving time change, the converter used to give up and emit the raw cron expression — while still labeling it with the display timezone. Anything consuming schedule:list --json would then read the event's local time as if it were UTC. The converter now always applies the UTC offset in effect at the next run date, matching the behavior it already had whenever both offsets agreed.
Why the old behavior was wrong
The converter computed the hour/minute offset between the event's timezone and the display timezone, but then sanity-checked it against the second upcoming run. If the two offsets differed — which happens exactly when a DST transition falls between the next run and the one after it — the method bailed out and returned the event's original expression untouched.
The problem is that the untouched expression is written in the event's local time. Displayed with a timezone label of the display timezone, it silently misstates when the task actually runs. How often this bites depends on cadence: a daily task only hits it in the hours around a DST change, but a monthly task can hit it for up to a month, because its next two runs can sit on either side of the transition.
Schedule::command('inspire')
->monthlyOn(1, '09:00')
->timezone('Europe/London');
Listing this on September 15th: the next run (October 1st) is in BST (UTC+1), the one after (November 1st) is in GMT (UTC+0). That mismatch triggered the old fallback.
What the patch changes
In CronExpressionTimezoneConverter, offsetComponents() no longer returns a nullable array and no longer fetches the second run date to cross-check offsets. It simply computes the offset at $event->nextRunDate() and always returns the components. The null fallback in forEvent() — the branch that returned [$event->expression] — is gone entirely. The early return when the total offset is zero (same UTC offset, no conversion needed) is unchanged, so expressions in matching timezones are untouched.
The test suite was updated to match: the old "falls back across DST transition" test now asserts a real conversion (0 0,2 * * * → 0 1,23 * * * for a twice-nightly London task listed just before the clocks change), and a new test covers the monthly case above.
Before and after
For the monthly London example, listed with --timezone UTC --json on September 15th:
// Before
[ { "expression": "0 9 1 * *", "timezone": "UTC" } ]
// After
[ { "expression": "0 8 1 * *", "timezone": "UTC" } ]
The old output claims the task runs at 09:00 UTC; it actually runs at 08:00 UTC (09:00 BST). The new output states that correctly. The same conversion now behaves consistently regardless of where the next runs land relative to a DST boundary — the offset at the next run date always wins.
The inherent caveat
The fix is correct, but it can't be magic: a single static cron expression cannot follow DST. "09:00 local time in London" is two different UTC times depending on the season, and one expression can only encode one of them. So a converted expression is accurate until the next DST change, at which point schedule:list must be re-run to regenerate it. For the same monthlyOn(1, '09:00') London event:
| Listed on | Next run (UTC) | Produced expression |
|---|---|---|
| 2024-09-15 | Oct 1, 08:00 | 0 8 1 * * |
| 2024-10-15 | Nov 1, 09:00 | 0 9 1 * * |
| 2025-02-15 | Mar 1, 09:00 | 0 9 1 * * |
| 2025-03-15 | Apr 1, 08:00 | 0 8 1 * * |
(February is an interesting edge case: it still takes the "next run" path, but the raw expression happens to be right, because London sits at UTC+0 in winter.)
Upgrade impact
Coming from v13.34.0, there is nothing to configure and no public API changes — CronExpressionTimezoneConverter is an internal detail of the scheduler console layer. The visible change is limited to schedule:list output near DST boundaries:
- JSON consumers benefit. Dashboards, external cron importers, or monitoring tools that ingest
schedule:list --jsonwill now receive expressions whose UTC times are correct even when the upcoming runs straddle a clock change. Previously they could silently fire an hour off. - Update brittle assertions. If your own tests snapshot
schedule:listoutput around DST transition dates, expect the converted expressions to change where the old fallback used to emit the raw expression. - Re-run after DST changes. If you persist converted expressions, regenerate them after each DST transition — that limitation is inherent to static cron expressions, not this patch.
Takeaways
schedule:listno longer falls back to the raw expression when the next two runs have different UTC offsets; it always converts using the offset at the next run date.- Previously, such output was labeled with the display timezone but actually contained the event's local time — a real hazard for
--jsonconsumers. - Monthly tasks were most exposed (up to a month of wrong output); daily tasks only near the transition itself.
- Converted expressions remain valid only until the next DST change; re-run
schedule:listafter each one. - No API or config changes are required to upgrade — the fix is automatic.