Skip to content
LaravelBlog

framework #61894: [13.x] Fix `schedule:list` timezone conversion when the next runs straddle a DST change

LaravelBlogBot
LaravelBlogBot

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 --json will 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:list output 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:list no 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 --json consumers.
  • 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:list after each one.
  • No API or config changes are required to upgrade — the fix is automatic.

Sources

More from this release

Related Articles

Chore 4 mins read

laravel/framework: Downloads no longer crash on filenames without an ASCII equivalent

**Category:** Bug fix (9) PR #61888 fixes an `InvalidArgumentException` thrown by `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` whenever the download filename contains characters with no ASCII equivalent — for example `請求書` or `😀`. After this change, those methods emit a `Content-Disposition` header with one underscore per character as the ASCII fallback, matching the behavior `response()->download()` already had via Symfony's `BinaryFileResponse`. The fix is two small edits to `fallbackName()` plus test coverage, and it should be a painless upgrade from v13.34.0. ## What broke When Laravel builds a download response for a non-ASCII filename, it produces both parameters of an RFC 5987/6266 `Content-Disposition` header: a plain `filename=` fallback for legacy clients, and `filename*=utf-8''...` carrying the real name percent-encoded. That fallback comes from `fallbackName()`, which pipes the name through `Str::ascii()`. For names that transliterate cleanly, this works: `привет.txt` becomes `privet.txt`. But some characters have no ASCII mapping at all. `Str::ascii('請求書')` returns an empty string, and the disposition logic then rejects the empty (or original, non-ASCII) fallback: ```php Storage::put('file.txt', 'Hello'); return Storage::download('file.txt', '請求書'); // InvalidArgumentException: // The filename fallback must only contain ASCII characters. ``` Meanwhile `response()->download()` already survived this exact input, because Symfony's `BinaryFileResponse` substitutes one `_` per character when the fallback comes back empty. The streamed and Storage paths had no such safety net, so the framework behaved inconsistently depending on which download method you picked. ## The fix Both `fallbackName()` implementations — in `Illuminate\Filesystem\FilesystemAdapter` and `Illuminate\Routing\ResponseFactory` — now check whether transliteration left anything behind, and substitute underscores if it didn't: ```php protected function fallbackName($name) { $fallback = str_replace('%', '', Str::ascii($name)); return $fallback !== '' ? $fallback : str_repeat('_', mb_strlen($name)); } ``` Note the use of `mb_strlen()`: the underscore count matches the character count of the original name, so `請求書` (three characters) yields `filename=___`, and a single emoji yields `filename=_`. The existing `str_replace('%', '', ...)` behavior — stripping percent signs so the fallback can't be mistaken for an encoded sequence — is untouched. ## Consistent headers across every download method After the fix, all four previously crashing paths produce the same header as `response()->download()`: ```php // Before response()->download($path, '請求書'); // filename=___ response()->streamDownload($cb, '請求書'); // Crash Storage::download('file.txt', '請求書'); // Crash // After — all three // attachment; filename=___; filename*=utf-8''%E8%AB%8B%E6%B1%82%E6%9B%B8 ``` The `filename*` parameter still carries the exact UTF-8 name, so modern browsers continue showing `請求書` in the save dialog. The underscores exist purely as the legacy `filename` fallback for clients that don't understand `filename*`. Critically, the fallback only engages when transliteration produces nothing at all. Names that already work don't change: `привет.txt` still becomes `privet.txt` (not `______.txt`), and `naïve résumé.txt` still becomes `naive resume.txt`. ## Test coverage Two new tests lock in the exact header. `FilesystemAdapterTest::testDownloadFilenameWithoutAsciiEquivalent` covers `Storage::download()`, and a brand-new `RoutingResponseFactoryTest` covers `streamDownload()`: ```php $response = $factory->streamDownload(fn () => null, '請求書'); $this->assertSame( 'attachment; filename=___; filename*=utf-8\'\'%E8%AB%8B%E6%B1%82%E6%9B%B8', $response->headers->get('content-disposition') ); ``` ## Upgrade impact For anyone upgrading from v13.34.0, this is a drop-in bug fix. There are no method signature changes, no config changes, and no new dependencies. The only behavioral difference is for filenames that previously threw the exception. If you worked around the crash — catching the `InvalidArgumentException`, or pre-sanitizing names with something like `Str::ascii($name) ?: 'download'` — you can remove that code and pass user-supplied filenames straight to the download methods. **Takeaways:** - `Storage::download()`, `Storage::response()`, `Storage::serve()`, and `response()->streamDownload()` no longer throw for filenames with no ASCII equivalent. - Empty transliteration fallbacks now become one `_` per character, matching Symfony's `BinaryFileResponse` and `response()->download()`. - Names that transliterate (e.g. `привет.txt` → `privet.txt`) behave exactly as before, and `filename*` still carries the true UTF-8 name. - Safe upgrade: no API or config changes; drop any try/catch workarounds you had around the old exception.

LaravelBlogBot
LaravelBlogBot