From ae26b8fadc7ed6908e70dbcf69e90dab8f730d9b Mon Sep 17 00:00:00 2001 From: Elliot Knight Date: Thu, 23 Jul 2026 18:40:36 +0100 Subject: [PATCH] Convert stepped cron expressions across timezones --- .../CronExpressionTimezoneConverter.php | 39 ++++++++++++++++++- .../Scheduling/ScheduleListCommandTest.php | 27 +++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php b/src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php index d15b8185ad79..8129b7f42aea 100644 --- a/src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php +++ b/src/Illuminate/Console/Scheduling/CronExpressionTimezoneConverter.php @@ -125,13 +125,19 @@ protected static function expressionsForHourCarry(array $segments, array $parts, */ protected static function shiftAndGroup($field, $offset, $mod, $min = 0) { - if ($offset === 0 || ! preg_match('/^[\d,]+$/', $field)) { + if ($offset === 0) { + return [0 => $field]; + } + + $values = static::expandField($field, $mod, $min); + + if ($values === null) { return [0 => $field]; } $groups = []; - foreach (explode(',', $field) as $value) { + foreach ($values as $value) { $new = (int) $value + $offset; $carry = 0; @@ -153,6 +159,35 @@ protected static function shiftAndGroup($field, $offset, $mod, $min = 0) })->all(); } + /** + * Expand a cron field containing stepped values. + * + * @param string $field + * @param int $mod + * @param int $min + * @return array|null + */ + protected static function expandField($field, $mod, $min = 0) + { + if (preg_match('/^[\d,]+$/', $field)) { + return array_map('intval', explode(',', $field)); + } + + if (! preg_match('/^(?\*|\d+)(?:-(?\d+))?\/(?\d+)$/', $field, $matches)) { + return null; + } + + $start = $matches['start'] === '*' ? $min : (int) $matches['start']; + $end = $matches['end'] !== '' ? (int) $matches['end'] : $mod + $min - 1; + $step = (int) $matches['step']; + + if ($step === 0 || $start < $min || $end >= $mod + $min || $start > $end) { + return null; + } + + return range($start, $end, $step); + } + /** * Shift a cron field by the given offset. * diff --git a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php index 884c948c1ea3..509c0d619747 100644 --- a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php @@ -256,14 +256,16 @@ public static function expressionTimezoneConversionProvider() // No conversion needed — same timezone 'same timezone' => ['0 8 * * *', 'UTC', null, ['0 8 * * *']], - // Wildcards/steps — pass through unchanged + // Wildcards and unsupported ranges — pass through unchanged 'every minute' => ['* * * * *', 'America/New_York', null, ['* * * * *']], 'every five minutes' => ['*/5 * * * *', 'Asia/Tokyo', null, ['*/5 * * * *']], - 'every two hours' => ['0 */2 * * *', 'Asia/Tokyo', null, ['0 */2 * * *']], - 'every odd hour' => ['0 1-23/2 * * *', 'Asia/Tokyo', null, ['0 1-23/2 * * *']], 'quarterly step month' => ['0 0 1 1-12/3 *', 'Asia/Tokyo', null, ['0 15 31 1-12/3 *']], 'weekdays range' => ['0 8 * * 1-5', 'Asia/Tokyo', null, ['0 23 * * 1-5']], + // Stepped hours + 'every two hours' => ['0 */2 * * *', 'Asia/Tokyo', null, ['0 15,17,19,21,23 * * *', '0 1,3,5,7,9,11,13 * * *']], + 'every odd hour' => ['0 1-23/2 * * *', 'Asia/Tokyo', null, ['0 16,18,20,22 * * *', '0 0,2,4,6,8,10,12,14 * * *']], + // Simple hour shift (no day boundary) 'daily LA to UTC' => ['0 8 * * *', 'America/Los_Angeles', null, ['0 16 * * *']], 'daily Tokyo to UTC' => ['0 14 * * *', 'Asia/Tokyo', null, ['0 5 * * *']], @@ -343,6 +345,25 @@ public function testExpressionTimezoneConversion($expression, $eventTimezone, $d } } + public function testExpressionTimezoneConversionHandlesSteppedHours() + { + Carbon::setTestNow('2023-07-01'); + + $this->schedule->command('inspire')->everyFourHours()->timezone('Europe/London'); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, [ + '--timezone' => 'UTC', + '--json' => true, + ]); + + $data = json_decode(Artisan::output(), true); + + $this->assertSame([ + '0 23 * * *', + '0 3,7,11,15,19 * * *', + ], array_column($data, 'expression')); + } + public function testDisplayScheduleCliSplitsExpressionWhenMixedCarry() { // 13+8=21 (no carry), 17+8=1 (carry +1) — splits into two CLI rows