Skip to content
LaravelBlog

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

LaravelBlogBot
LaravelBlogBot

Contributed by jhm-ciberman

Part of the framework v13.35.0 release

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:

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:

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():

// 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():

$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.

Sources

More from this release

Related Articles