Keeping Storage::path() inside the disk root

Storage::path() could return a path outside its configured disk. Reading a file and building its path should agree on that boundary.

I fixed an inconsistency in Laravel's Storage::path(). A path that tried to leave the configured disk would be rejected by operations such as get(), but path() could still return a string pointing outside that disk.

That matters when the returned string gets passed to something that actually opens a file. A disk configured for application uploads shouldn't hand back paths to unrelated files on the server.

What path() actually returns

On a local disk, path() gives you a filesystem path. It doesn't read the file or check whether it exists.

For example, suppose your local disk's root is /srv/app/storage/app/private, and it contains exports/monthly-report.csv. A controller could use that file with Laravel's download response:

// app/Http/Controllers/ReportController.php
// Imports: Illuminate\Support\Facades\Storage;
//          Symfony\Component\HttpFoundation\BinaryFileResponse;
public function download(): BinaryFileResponse
{
    $path = Storage::disk('local')->path('exports/monthly-report.csv');

    return response()->download($path);
}

Here, $path is /srv/app/storage/app/private/exports/monthly-report.csv.

The important distinction is that the download response opens the file itself. It doesn't go back through the storage disk to validate that path.

Where the two operations differed

Before the fix, FilesystemAdapter::path() only passed its argument to PathPrefixer::prefixPath(). That joined the disk's prefix with the supplied path without normalizing it first.

Reads went through Flysystem's path normalizer. Building a path skipped that step. So the two operations could disagree about whether the same input stayed inside the disk.

Even an ordinary path such as exports/./monthly-report.csv showed the difference: path() left the ./ segment in the returned string, while the driver normalized it away.

Normalize before prefixing

PR #61343 adds Flysystem's WhitespacePathNormalizer before the prefix is applied. Here's the complete patched method:

// src/Illuminate/Filesystem/FilesystemAdapter.php
// Added import: use League\Flysystem\WhitespacePathNormalizer;
public function path($path)
{
    return $this->prefixer->prefixPath((new WhitespacePathNormalizer)->normalizePath($path));
}

Despite its name, the normalizer does more than handle whitespace. It cleans up separators and relative segments, and throws PathTraversalDetected if resolving the path would go above its starting boundary.

The order matters: normalize the disk-relative path first, then attach the configured root and any scoped prefix.

For the ./ example, the returned strings look like this:

Before: /srv/app/storage/app/private/exports/./monthly-report.csv
After:  /srv/app/storage/app/private/exports/monthly-report.csv

Both refer to the same file. For a path that attempts to leave the disk, the patched method throws instead of returning an out-of-bounds path.

What the tests cover

The regression tests check that paths cannot climb above either the disk root or a configured prefix. They cover both slash styles and verify that relative segments within the disk still resolve to the expected file.

That last check matters. The fix doesn't reject every relative segment; it rejects paths that cross the boundary.

An application still needs to decide which files a user may access. Normalizing a path also doesn't check whether the file exists or resolve filesystem symlinks.

Merged into Laravel 13.x

My PR #61343 was merged into 13.x on August 27, 2026. The change brings path() in line with Flysystem's default path normalization.

Thanks for reading.
Hristijan