Caption: The local disk is storage/app/private (or whatever root you set). .env lives outside that root. get / delete / readStream already refused a walk out. path() was the leftover.
Introduction
I keep a throwaway Laravel 13 app on a VPS for filesystem work. Not a client box. A place I can composer update laravel/framework and see what actually throws.
This week the thing I cared about was not chunkBy() and not php artisan dev. It was the method I reach for when I hand a file to something that is not Flysystem: response()->download(), Imagick, a CLI that wants a native path.
Storage::path() on 13.29 still concatenated. Storage::get() of the same argument already went through Flysystem and refused to leave the disk. I have shipped downloads that look like Storage::path($request->query('path')). On a local disk that string becomes an absolute path. PHP then serves whatever that path points at.
Laravel 13.30.0 shipped 1 Sep 2026 (Packagist 2026-09-01T13:19:00+00:00). 13.30.1 followed the same day. As of 8 Sep 2026, Packagist’s latest 13.x is v13.31.0. The hardening I want is in 13.30.0. I still run composer show laravel/framework before I claim the throw.
This is a lab note from the framework’s own tests and from Laravel News (2 Sep 2026). I did not invent a CVE number. There was not one published for this change at write time.
About the feature
Storage::path($relative) is the method that turns a disk-relative key into something the rest of PHP understands. On the local driver, that is an absolute filesystem path. On S3, the docs still say you get the relative key in the bucket — not a local file.
Until 13.30, Illuminate\Filesystem\FilesystemAdapter::path() handed the string to Flysystem’s PathPrefixer::prefixPath(). Prefixer concatenates. It does not resolve ... It does not ask whether you walked out of root.
Every other call — get(), delete(), readStream(), put() — goes through League\Flysystem\Filesystem, which builds a WhitespacePathNormalizer and throws when the normalized path escapes the disk.
13.30 makes path() do the same first step. The method in v13.30.1 is one line:
public function path($path)
{
return $this->prefixer->prefixPath(
(new WhitespacePathNormalizer)->normalizePath($path)
);
}
normalizePath() turns backslashes into slashes, drops . and empty segments, and pops a segment on ... If .. would walk above the start of the path, Flysystem throws:
League\Flysystem\PathTraversalDetected
Path traversal detected: ../../../.env
That exception class is League\Flysystem\PathTraversalDetected, not an Illuminate wrapper. ReceiveFile in the same namespace already uses the League class and abort(404)s on it. I would catch the same one.
Internal relatives that stay inside the disk still work. The framework test writes foo/bar.txt and then asks for foo/baz/../bar.txt. Both get() and path() resolve to the same file. That is not a traversal. Walking to ../../../.env is.
Caption: Before 13.30, get() normalized and path() only prefixed. After PR #61343, path() runs WhitespacePathNormalizer first. The exception is League\Flysystem\PathTraversalDetected.
The 13.x filesystem docs still only show this:
use Illuminate\Support\Facades\Storage;
$path = Storage::path('file.jpg');
There is no new docs chapter. The behavior change is the PR and the tests, not a heading on laravel.com.
Why I picked it
Sunday’s queue pointed at Laravel News from 2 Sep 2026: Storage::path() now goes through the same normalizer as every other filesystem call. The week field on that item is 2026-W36. I am writing on 9 Sep 2026 (W37). No newer Wednesday pick replaced it, so this is the lowest-priority undone feature.
I picked it because I still write downloads against a local disk. Private invoices, exports, a PDF I just rendered. Storage::download() exists. I still use path() when the next consumer is not Laravel’s download helper.
On 13.29, composer show laravel/framework printing 13.29.0 means path() can still walk out. I would not argue with a changelog. I would run the two calls side by side in tinker on that app.
Where it can be used
This is not “any Laravel app.” It is the places I turn a user-controlled string into a native path:
response()->download(Storage::path(...))andresponse()->file(...)- streaming a local file into Imagick, FFmpeg, or a CLI that wants
--input /absolute/path - a signed download action that still takes a
pathquery or route parameter - a scoped disk (
prefixinconfig/filesystems.php) — the same tests rejectpath('../file.txt')walking out of that prefix - a job that builds a zip from keys the request sent
S3 is the wrong panic. On S3, path() is a relative key. The dangerous shape is the local disk plus response()->download(), because PHP then opens whatever absolute path you gave it.
I would also grep storage_path('app/'.$request-> in the same pass. That helper never went through Flysystem. 13.30 does not save a concatenation I wrote myself.
Benefit
get(), delete(), readStream(), and path() now agree about what a path means. I can hand path() to a download and know a .. that escapes the disk throws instead of returning a string that points at .env.
That is defense in depth, not a replacement for authorizing the file. A user can still ask for invoices/someone-elses.pdf if I never check ownership. The throw only stops leaving the disk root.
The package map is short. I do not composer require Flysystem in a normal app. laravel/framework v13.30.1 already requires league/flysystem: ^3.25.1 and league/flysystem-local: ^3.25.1. Those are the packages under this call. I am not inventing Intervention or Scout here.
Caption: FilesystemAdapter::path() lives in laravel/framework. It constructs League\Flysystem\WhitespacePathNormalizer, may throw League\Flysystem\PathTraversalDetected, then prefixes with PathPrefixer. Confirmed in the v13.30.1 composer.json require list.
Practical example from a recent application
Lab app, not a named client. Default local disk rooted at storage/app/private (Laravel 13’s private disk). Experience notes I kept for this item: confirm composer show laravel/framework is 13.30.0+ before claiming the throw; on 13.29 path() still concatenated; the download from ?path= is the lab; docs still only show Storage::path('file.jpg'); code that relied on path() accepting .. will start throwing League\Flysystem\PathTraversalDetected; do not invent a CVE.
composer show laravel/framework | sed -n '1,8p'
# name : laravel/framework
# versions : * v13.31.0 # 8 Sep 2026 on Packagist. Hardening landed in 13.30.0.
If that first line still says 13.29.x, stop. The rest of this example will not throw.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\PathTraversalDetected;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class InvoiceDownloadController
{
public function __invoke(Request $request): BinaryFileResponse
{
$key = $request->string('path')->toString();
try {
$absolute = Storage::disk('local')->path($key);
} catch (PathTraversalDetected $e) {
report($e);
abort(404);
}
// Ownership still belongs in the app. The throw only keeps us on the disk.
abort_unless(Storage::disk('local')->exists($key), 404);
return response()->download($absolute);
}
}
Failure I would hit on 13.29. Same controller. Query path=../../../.env. get() already refused. path() returned a native path that pointed at the application .env. response()->download() then did what PHP does with an absolute file: it sent it.
use Illuminate\Support\Facades\Storage;
// Documented disagreement (Laravel News, 2 Sep 2026; framework tests).
Storage::get('../../../.env'); // already PathTraversalDetected on 13.29
Storage::path('../../../.env'); // 13.29: absolute path past the disk root
// 13.30+: League\Flysystem\PathTraversalDetected
The framework tests also cover ..\..\..\.env, /../../../.env, .., and foo/../../../.env. Windows separators do not save anyone — the normalizer rewrites \ to / first.
Fix. Bump laravel/framework to 13.30.0 or newer. Catch League\Flysystem\PathTraversalDetected and abort(404) the way ReceiveFile already does. Prefer a stored key I issued (invoices/2026-09-09-abc.pdf) over a raw query path. If I must accept a name, basename() plus a disk I control is still the app check. The framework throw is the backstop I was missing.
If I had code that wanted path() to accept a walk above the disk, that code is now wrong. I would stop doing that. Put the file on the disk, or open it with a path I constructed from config — not from Storage::path('../../../something').
Caption: response()->download(Storage::path($request->query('path'))) is the shape PR #61343 called out. On 13.30+ the normalizer throws and I abort 404. Official docs still only demonstrate Storage::path('file.jpg').
Conclusion
What I would keep: laravel/framework at 13.30.0+ (today that update lands me on 13.31.0), path() only for keys I already trust or that I wrap in PathTraversalDetected, and a grep for storage_path('app/'.$request that the framework will never fix for me.
What is running in the lab now is a 13.31 app where Storage::path('../../../.env') throws the League exception and a valid invoices/demo.pdf still resolves inside storage/app/private. Next I would grep the same app for response()->file( and for any path() call that still interpolates a request value without the catch.
Did you hit the same wall?
I got stuck on Storage::path() still concatenating on 13.29 while get() already refused ../../../.env. Did you hit the same thing — a download that started throwing PathTraversalDetected after composer update, a scoped disk that now rejects ../file.txt, or a storage_path() concatenation the framework never sees? Tell me in the comments. I read them.
Need this done on your server?
I deploy and harden Laravel/CodeCanyon apps on cPanel or VPS, and offer monthly Server Watch retainers. Hire for deploy · Care plan
References
- Laravel filesystem docs (retrieved 9 Sep 2026;
path()example is stillfile.jpgonly): https://laravel.com/docs/13.x/filesystem - Laravel News, 2 Sep 2026, “Collections chunkBy() and Storage Path Hardening in Laravel 13.30”: https://laravel-news.com/laravel-13-30-0
- Framework PR #61343 — Confine
Storage::path()to the configured disk root laravel/frameworkv13.30.0 on Packagist, 1 Sep 2026 13:19 UTC: https://packagist.org/packages/laravel/framework#v13.30.0laravel/frameworkv13.30.1 on Packagist, 1 Sep 2026 21:42 UTC: https://packagist.org/packages/laravel/framework#v13.30.1laravel/frameworkv13.31.0 on Packagist, 8 Sep 2026 (latest 13.x at write time; hardening is not new in 13.31)FilesystemAdapter::path()at v13.30.1: https://github.com/laravel/framework/blob/v13.30.1/src/Illuminate/Filesystem/FilesystemAdapter.php- Tests
testPathRejectsTraversalOutsideOfTheRoot/testPathResolvesRelativeSegmentsTheSameWayTheDriverDoesin v13.30.0FilesystemAdapterTest.php League\Flysystem\PathTraversalDetected(Path traversal detected: {path}): https://github.com/thephpleague/flysystem/blob/3.x/src/PathTraversalDetected.phpLeague\Flysystem\WhitespacePathNormalizer: https://github.com/thephpleague/flysystem/blob/3.x/src/WhitespacePathNormalizer.phplaravel/frameworkv13.30.1composer.jsonrequiresleague/flysystem: ^3.25.1andleague/flysystem-local: ^3.25.1