Skip to content

[13.x] Fix schedule:list timezone conversion for range, step, and wildcard cron expressions#60877

Open
xiCO2k wants to merge 3 commits into
laravel:13.xfrom
xiCO2k:fix/schedule-list-convert
Open

[13.x] Fix schedule:list timezone conversion for range, step, and wildcard cron expressions#60877
xiCO2k wants to merge 3 commits into
laravel:13.xfrom
xiCO2k:fix/schedule-list-convert

Conversation

@xiCO2k

@xiCO2k xiCO2k commented Jul 24, 2026

Copy link
Copy Markdown
Member

This PR is a follow-up to #59286. The converter only shifts plain numeric fields, so ranges (8-23), steps (*/2), and wildcards pass through unchanged while the output still labels them with the display timezone. Anything consuming schedule:list --json will compute the wrong run times for those.

Example

Schedule::command('inspire')
    ->cron('*/10 8-23 * * *')
    ->timezone('Europe/Amsterdam'); // UTC+2 (CEST)

Before

$ php artisan schedule:list --timezone UTC

  */10 8-23 * * *  php artisan inspire  Next Due: 3 minutes from now
[
  {
    "expression": "*/10 8-23 * * *",
    "timezone": "UTC"
  }
]

The expression claims the task runs 08:00-23:59 UTC. It actually runs 08:00-23:59 Amsterdam time (06:00-21:59 UTC).

After

$ php artisan schedule:list --timezone UTC

  */10 6-21 * * *  php artisan inspire  Next Due: 3 minutes from now
[
  {
    "expression": "*/10 6-21 * * *",
    "timezone": "UTC"
  }
]

The converter now expands any field into its values, shifts them, and collapses the result back into compact syntax (*, a-b, */s, a-b/s, or a comma list). More examples:

Expression Event timezone Before (display UTC) After
0 0-4 * * * America/New_York 0 0-4 * * * 0 5-9 * * *
0 */2 * * * Asia/Tokyo 0 */2 * * * 0 1-23/2 * * *
0 8 * * 1-5 Asia/Tokyo 0 23 * * 1-5 0 23 * * 0-4
0 0 1 1-12/3 * Asia/Tokyo 0 15 31 1-12/3 * 0 15 31 3-12/3 *

The last two were converted before, but wrongly. The hour was shifted across midnight while the day-of-week/month ranges stayed behind. They now shift together with the carry.

Some nastier ones, with fractional offsets and boundary crossings:

Expression Event timezone Before (display UTC) After
0 8-17 * * 1-5 Pacific/Chatham (UTC+12:45) 15 8-17 * * 1-5 15 19-23 * * 0-4
15 0-4 * * 1-5
* * 1 1 * Asia/Kathmandu (UTC+5:45) * * 1 1 * 15-59 18-23 31 12 *
15-59 0-17 1 1 *
0-14 19-23 31 12 *
0-14 0-18 1 1 *
*/15 9-17 * * * Asia/Kathmandu */15 9-17 * * * 15-45/15 3-11 * * *
0 4-12 * * *
0 22-23 * * 5 America/Los_Angeles 0 22-23 * * 5 0 5,6 * * 6

Note the Chatham case: the old code shifted the minute by the fractional offset but left the hours and weekdays alone, producing an expression that is correct in no timezone.

Some edge-cases

Splitting still works as in #59286. When a shifted range crosses midnight and a day field is fixed, the event is split:

$ php artisan schedule:list --json
// 0 */2 15 * * in Asia/Tokyo

[
  { "expression": "0 15-23/2 14 * *", "timezone": "UTC" },
  { "expression": "0 1-13/2 15 * *", "timezone": "UTC" }
]

When the day fields are all wildcards, a day carry is irrelevant (every day matches), so mixed carries now merge into a single entry instead. twiceDaily(13, 17) in America/Los_Angeles now produces one line, 0 1,21 * * *, where #59286 produced two.

Expressions using syntax that cannot be safely shifted (L, W, #, ?, or named values like MON) in a field that needs shifting are returned unchanged, as before. The converter never emits a half-converted expression.

Tests

Updated the cases that pinned the old pass-through behavior and added coverage for ranges, steps, wildcards, half-hour offsets, day carries, merging, and the fallbacks. Also verified the converted expressions produce the exact same run instants as the originals evaluated in their own timezone.

@taylorotwell

Copy link
Copy Markdown
Member

Some stuff found by my agent review

  • src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php:145 — Day-of-month carry assumes every month has 31 days, so the tested conversion of 0 0 1 1-12/3 * loses the June 30 and September 30 UTC runs.
  • src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php:143 — A wildcard day-of-month discards day carry entirely, making restricted-month schedules such as 0 1 * 1 * omit December 31 and incorrectly include January 31 after conversion.
  • src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php:135 — Shifting DOM, month, and DOW together breaks cron’s DOM OR DOW semantics; for example, 0 1 1 3 1 becomes 0 16 31 2 0, introducing February runs and removing March Mondays.
  • src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php:62 — Using only today’s timezone offset produces expressions that are wrong in the other DST season, contradicting the PR’s goal of allowing JSON consumers to calculate run times reliably.

@taylorotwell
taylorotwell marked this pull request as draft July 24, 2026 15:09
@egknight

Copy link
Copy Markdown

Tested f90bc92f9e against the Europe/London → UTC schedule:list --json case that triggered my issue.

On the current 13.x base, stepped four-hour expressions remain unchanged during BST and are therefore incorrectly labelled UTC.

With this PR:
30 1-23/4 * * * → 30 */4 * * * during BST
30 1-23/4 * * * remains unchanged during GMT
30 */4 * * * → 30 3-23/4 * * * during BST
30 */4 * * * remains unchanged during GMT

I also tested immediately after both 2026 Europe/London DST transitions and compared the next 12 actual run timestamps across six scenarios. Every converted UTC timestamp matched the original expression evaluated in Europe/London.

Focused result on PHP 8.5.7: 87 tests, 344 assertions passed.

Thanks a lot

@xiCO2k
xiCO2k marked this pull request as ready for review July 24, 2026 23:48
@xiCO2k

xiCO2k commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@taylorotwell implemented those suggestions.

@xiCO2k
xiCO2k force-pushed the fix/schedule-list-convert branch from f90bc92 to 5b3081b Compare July 24, 2026 23:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants