Short version for the impatient: Laravel 13.30 gives collections a chunkBy() method, and it quietly closes a hole where Storage::path() would happily hand you a filesystem path to your .env file. If you have a download endpoint that takes a path from the request, go grep for Storage::path before you read the rest of this.
Now the longer version. I almost skipped this release. Point releases in Laravel land are a weekly thing now, and most of them are the kind of change you nod at and forget. I opened the changelog on Wednesday morning mostly out of habit, saw “chunkBy”, thought “nice, another helper”, and was about to close the tab. Then I got to the part about Storage::path() and went a bit cold, because I had written the exact vulnerable line in a client project in 2023. I checked. It had been refactored out since, but not because anyone noticed the problem. We just moved to signed URLs for other reasons. That’s luck, not engineering.
So this post is two things. A look at chunkBy(), because I like collection helpers and this one replaces a pattern I’ve typed dozens of times. And a slightly more serious look at what changed in Storage::path(), why it was inconsistent with the rest of the filesystem layer for years, and what it might break when you upgrade.
What chunkBy() replaces
Laravel’s collections have had chunk() forever. It splits a collection into pieces of a fixed size. Useful for batching, useless for grouping adjacent items that share something.
For that, the tool since Laravel 8 has been chunkWhile(). It takes a callback and starts a new chunk whenever the callback returns false. Every time I’ve used it, the callback compared the current item to the last item of the chunk being built:
// Before: chunkWhile with the comparison you always end up writing
$grouped = $orders->chunkWhile(
fn ($order, $key, $chunk) => $order->customer_id === $chunk->last()->customer_id
);
That works. It also has a shape I have to re-derive every time. Which argument is the chunk? Is it ->last() or ->first() I want? Does == or === matter for the ids coming out of this particular database driver? None of those are hard questions, but they’re the kind of questions that cost me a minute of staring each time, and a minute of staring is exactly what a helper should remove.
chunkBy() is that comparison as a method:
// After: chunkBy with a key
$grouped = $orders->chunkBy('customer_id');
// Or a callback, if the grouping value needs computing
$grouped = $orders->chunkBy(fn ($order) => $order->created_at->toDateString());
// Dot notation works, because the key goes through data_get()
$grouped = $addresses->chunkBy('address.city');
Two details from the pull request that I care about. The key resolves via data_get(), so nested keys and objects behave the way they do everywhere else in the framework. And the original keys are preserved inside each chunk, which matters if you’re chunking a keyed collection and want to write the results back somewhere by id.
It also lands on LazyCollection, which is the version I’ll use most. A common job I write is “walk a big export in order, emit one file per group”. With chunkWhile on a lazy collection I always had a nagging feeling that the $chunk->last() lookup was doing more work than it needed to. With chunkBy I stop thinking about it.
The thing to remember: it’s adjacency, not grouping
Here’s where I’ll push back on how I’ve seen this described online already. chunkBy is not groupBy with a different name. It only groups items that are next to each other.
collect([1, 1, 2, 2, 1, 1])->chunkBy(fn ($v) => $v);
// [[1, 1], [2, 2], [1, 1]] <- three chunks, not two
collect([1, 1, 2, 2, 1, 1])->groupBy(fn ($v) => $v);
// [1 => [1, 1, 1, 1], 2 => [2, 2]] <- two groups
That’s the whole point of it, and it’s the whole trap. If your query doesn’t have an orderBy on the column you’re chunking by, you’ll get fragmented chunks and probably not notice in dev because your seed data happens to be sorted. I have been bitten by this with chunkWhile and I’ll be bitten by it with chunkBy. The fix is boring: sort first, then chunk, and put a test in with deliberately shuffled input.
When do you want adjacency instead of a full group? Usually when the order carries meaning. Consecutive log lines from the same request. Runs of the same status in a timeline. Line items on an invoice that should be visually grouped under a parent without being reordered. groupBy would destroy the sequence; chunkBy keeps it.
Storage::path() finally goes through the normalizer
This is the part that made me check old projects.
Every filesystem call on a Laravel disk goes through Flysystem, and Flysystem normalizes the path before doing anything with it. If the normalized path resolves outside the disk root, it throws PathTraversalDetected. So this has been rejected for years:
Storage::get('../../../.env'); // throws PathTraversalDetected
Storage::path() was the one exception. It didn’t call into the driver’s read or write methods, it just asked the PathPrefixer to glue the disk root onto whatever string you gave it. No normalization, no traversal check. So on the default local disk:
// Before 13.30
Storage::path('../../../.env');
// => "/var/www/app/storage/app/../../../.env"
// which is a perfectly valid native path to your .env
get(), delete() and readStream() all refused that argument. path() returned a string pointing at your secrets. And the string is where it gets dangerous, because path() is the method you reach for when you want to hand a file to something that isn’t Flysystem. Like PHP’s own file functions. Or a response:
// The line I had in a 2023 project. Do not do this.
return response()->download(Storage::path($request->query('path')));
response()->download() doesn’t know or care about disk roots. It just streams whatever native path you give it. So the one Laravel method that skipped the traversal check was also the one most likely to be sitting directly in front of fopen.
As of #61343, path() runs the argument through WhitespacePathNormalizer, the same normalizer that League\Flysystem\Filesystem builds for every other operation. Relative segments now resolve the way the driver resolves them, and anything that escapes the root throws instead of returning a string.
// After 13.30
Storage::path('reports/../invoices/2026-08.pdf');
// => "/var/www/app/storage/app/invoices/2026-08.pdf" (resolved inside the root, fine)
Storage::path('../../../.env');
// throws League\Flysystem\PathTraversalDetected
I want to be fair here. This was a known inconsistency rather than a hidden zero-day, and a lot of teams never hit it because they don’t pass request input to path(). But “a lot of teams” isn’t “all teams”, and I was on one of the other kind. I wrote about the general shape of this problem, where the tool you use for the review is only as good as the surface it knows about, in my post on running a Laravel security audit with the agent you already use. That audit would have flagged the download() line. It would not have flagged path() itself as the cause, because until this week, path() behaving that way was just how it worked.
What this might break on upgrade
The flip side of a hardening change is that code depending on the old behaviour now throws.
If you have anything that deliberately uses .. in a path given to Storage::path(), it will now either resolve (if the result stays inside the root) or throw (if it doesn’t). The first case is silent and probably fine. The second case is an exception in production, and the framework isn’t going to guess whether you meant it.
The pattern I’ve seen most is a “shared” folder one level above the disk root that someone reached with Storage::path('../shared/thing.csv') because configuring a second disk felt like too much ceremony. That will now throw. The fix is to configure the second disk. It was always the right answer, it just wasn’t enforced.
The other one to watch is tests. If you have a test that asserts Storage::path() returns a specific string for a path with relative segments, the string changes even when nothing throws, because the segments now collapse. Search your test suite for Storage::path( and ->path( on a disk before you bump.
My own upgrade checklist for this release, in the order I actually did it:
grep -rn "Storage::path\|->path(" app/and read every hit.- For each hit, ask: can any part of this argument come from a request, a webhook payload, a queued job payload, or a database column a user can edit?
- If yes, either validate the path against an allow list before it reaches
path(), or stop usingpath()there entirely and usedownload()/response()on the disk, which go through the normalizer. - Run the suite. Anything that throws
PathTraversalDetectedgets fixed, not caught.
Step three is the real one. The 13.30 change means the traversal case throws, which is far better than silently returning a path. But an exception in front of a download endpoint is still a broken endpoint, and I’d rather reject bad input with a 422 than let it reach the filesystem layer at all.
The small one I’ll use every day: worker stop reasons
There’s a third change in this release that isn’t security related and isn’t as clever as chunkBy, but I suspect it’ll save me more time than either. queue:work now prints why the worker stopped as its last line of output.
Before, a worker that exited just exited. The WorkerStopping event has carried a WorkerStopReason for a while, but you had to register a listener to read it, which is a lot of ceremony for something you usually want to know while staring at a terminal at 11pm.
Now:
2026-09-01 13:20:40 Worker STOPPED Memory limit exceeded
With --json it’s a structured record, with the reason, exit code, jobs processed and memory alongside the per-job lines. The WorkerStopReason enum gained a description() method covering nine cases, including memory limit, max jobs, max time, queue empty, restart signal and job timeout. Nothing prints under --quiet or --silent, so it won’t spam a supervisor log.
This connects to something I wrote about in the queue defaults I stopped trusting in production. A large fraction of “why did my worker die” investigations end with “it hit the memory limit and Horizon restarted it”, and until now the evidence for that was indirect. Having the reason in the log line turns a fifteen minute investigation into a glance.
Was it worth a whole post?
I think so, and not because chunkBy is exciting. It’s because the Storage::path() change is the kind of thing that’s easy to miss in a weekly release note, and it has a real “go check your code” action attached to it. Most weeks the answer to “should I read the changelog” is “probably not”. This week it was yes.
A few other things in the release worth a glance if you’re on SQL Server (native sqlsrv: DSN strings now parse instead of getting mangled by parse_url), or if you build Artisan tooling (Artisan::commandNamed() resolves a single command without constructing all of them). The full list is in the Laravel News write-up and the official changelog.
If you want to see how I handle file handling and upgrades on client projects more generally, that’s covered on my work page.
What to do this week
Run this in every Laravel project you maintain, even the ones not on 13 yet:
grep -rn "Storage::path\|Storage::disk([^)]*)->path" app/ routes/
For every line it finds, trace where the argument comes from. If any part of it originates outside your own code, wrap it in validation now. Don’t wait for the upgrade to throw the exception for you. Then bump to 13.30, run your suite, and swap your first chunkWhile comparison for chunkBy while you’re in there. That last one is just for fun.