{"id":649,"date":"2026-09-04T13:04:17","date_gmt":"2026-09-04T13:04:17","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-13-30-chunkby-and-the-storage-path-line-i-had-in-production\/"},"modified":"2026-09-04T13:04:17","modified_gmt":"2026-09-04T13:04:17","slug":"laravel-13-30-chunkby-and-the-storage-path-line-i-had-in-production","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-13-30-chunkby-and-the-storage-path-line-i-had-in-production\/","title":{"rendered":"Laravel 13.30: chunkBy() and the Storage::path() Line I Had in Production"},"content":{"rendered":"<p>Short version for the impatient: Laravel 13.30 gives collections a <code>chunkBy()<\/code> method, and it quietly closes a hole where <code>Storage::path()<\/code> would happily hand you a filesystem path to your <code>.env<\/code> file. If you have a download endpoint that takes a path from the request, go grep for <code>Storage::path<\/code> before you read the rest of this.<\/p>\n<p>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 &ldquo;chunkBy&rdquo;, thought &ldquo;nice, another helper&rdquo;, and was about to close the tab. Then I got to the part about <code>Storage::path()<\/code> 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&rsquo;s luck, not engineering.<\/p>\n<p>So this post is two things. A look at <code>chunkBy()<\/code>, because I like collection helpers and this one replaces a pattern I&rsquo;ve typed dozens of times. And a slightly more serious look at what changed in <code>Storage::path()<\/code>, why it was inconsistent with the rest of the filesystem layer for years, and what it might break when you upgrade.<\/p>\n<h2 id=\"what-chunkby-replaces\">What chunkBy() replaces<\/h2>\n<p>Laravel&rsquo;s <a href=\"https:\/\/laravel.com\/docs\/13.x\/collections\" rel=\"nofollow noopener\" target=\"_blank\">collections<\/a> have had <code>chunk()<\/code> forever. It splits a collection into pieces of a fixed size. Useful for batching, useless for grouping adjacent items that share something.<\/p>\n<p>For that, the tool since Laravel 8 has been <code>chunkWhile()<\/code>. It takes a callback and starts a new chunk whenever the callback returns false. Every time I&rsquo;ve used it, the callback compared the current item to the last item of the chunk being built:<\/p>\n<pre><code class=\"language-php\">\/\/ Before: chunkWhile with the comparison you always end up writing\n$grouped = $orders-&gt;chunkWhile(\n    fn ($order, $key, $chunk) =&gt; $order-&gt;customer_id === $chunk-&gt;last()-&gt;customer_id\n);\n<\/code><\/pre>\n<p>That works. It also has a shape I have to re-derive every time. Which argument is the chunk? Is it <code>-&gt;last()<\/code> or <code>-&gt;first()<\/code> I want? Does <code>==<\/code> or <code>===<\/code> matter for the ids coming out of this particular database driver? None of those are hard questions, but they&rsquo;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.<\/p>\n<p><code>chunkBy()<\/code> is that comparison as a method:<\/p>\n<pre><code class=\"language-php\">\/\/ After: chunkBy with a key\n$grouped = $orders-&gt;chunkBy('customer_id');\n\n\/\/ Or a callback, if the grouping value needs computing\n$grouped = $orders-&gt;chunkBy(fn ($order) =&gt; $order-&gt;created_at-&gt;toDateString());\n\n\/\/ Dot notation works, because the key goes through data_get()\n$grouped = $addresses-&gt;chunkBy('address.city');\n<\/code><\/pre>\n<p>Two details from the <a href=\"https:\/\/github.com\/laravel\/framework\/pull\/61357\" rel=\"nofollow noopener\" target=\"_blank\">pull request<\/a> that I care about. The key resolves via <code>data_get()<\/code>, 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&rsquo;re chunking a keyed collection and want to write the results back somewhere by id.<\/p>\n<p>It also lands on <code>LazyCollection<\/code>, which is the version I&rsquo;ll use most. A common job I write is &ldquo;walk a big export in order, emit one file per group&rdquo;. With <code>chunkWhile<\/code> on a lazy collection I always had a nagging feeling that the <code>$chunk-&gt;last()<\/code> lookup was doing more work than it needed to. With <code>chunkBy<\/code> I stop thinking about it.<\/p>\n<h2 id=\"the-thing-to-remember-its-adjacency-not-grouping\">The thing to remember: it&rsquo;s adjacency, not grouping<\/h2>\n<p>Here&rsquo;s where I&rsquo;ll push back on how I&rsquo;ve seen this described online already. <code>chunkBy<\/code> is not <code>groupBy<\/code> with a different name. It only groups items that are next to each other.<\/p>\n<pre><code class=\"language-php\">collect([1, 1, 2, 2, 1, 1])-&gt;chunkBy(fn ($v) =&gt; $v);\n\/\/ [[1, 1], [2, 2], [1, 1]]  &lt;- three chunks, not two\n\ncollect([1, 1, 2, 2, 1, 1])-&gt;groupBy(fn ($v) =&gt; $v);\n\/\/ [1 =&gt; [1, 1, 1, 1], 2 =&gt; [2, 2]]  &lt;- two groups\n<\/code><\/pre>\n<p>That&rsquo;s the whole point of it, and it&rsquo;s the whole trap. If your query doesn&rsquo;t have an <code>orderBy<\/code> on the column you&rsquo;re chunking by, you&rsquo;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 <code>chunkWhile<\/code> and I&rsquo;ll be bitten by it with <code>chunkBy<\/code>. The fix is boring: sort first, then chunk, and put a test in with deliberately shuffled input.<\/p>\n<p>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. <code>groupBy<\/code> would destroy the sequence; <code>chunkBy<\/code> keeps it.<\/p>\n<h2 id=\"storagepath-finally-goes-through-the-normalizer\">Storage::path() finally goes through the normalizer<\/h2>\n<p>This is the part that made me check old projects.<\/p>\n<p>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 <code>PathTraversalDetected<\/code>. So this has been rejected for years:<\/p>\n<pre><code class=\"language-php\">Storage::get('..\/..\/..\/.env'); \/\/ throws PathTraversalDetected\n<\/code><\/pre>\n<p><code>Storage::path()<\/code> was the one exception. It didn&rsquo;t call into the driver&rsquo;s read or write methods, it just asked the <code>PathPrefixer<\/code> to glue the disk root onto whatever string you gave it. No normalization, no traversal check. So on the default local disk:<\/p>\n<pre><code class=\"language-php\">\/\/ Before 13.30\nStorage::path('..\/..\/..\/.env');\n\/\/ =&gt; &quot;\/var\/www\/app\/storage\/app\/..\/..\/..\/.env&quot;\n\/\/ which is a perfectly valid native path to your .env\n<\/code><\/pre>\n<p><code>get()<\/code>, <code>delete()<\/code> and <code>readStream()<\/code> all refused that argument. <code>path()<\/code> returned a string pointing at your secrets. And the string is where it gets dangerous, because <code>path()<\/code> is the method you reach for when you want to hand a file to something that isn&rsquo;t Flysystem. Like PHP&rsquo;s own file functions. Or a response:<\/p>\n<pre><code class=\"language-php\">\/\/ The line I had in a 2023 project. Do not do this.\nreturn response()-&gt;download(Storage::path($request-&gt;query('path')));\n<\/code><\/pre>\n<p><code>response()-&gt;download()<\/code> doesn&rsquo;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 <code>fopen<\/code>.<\/p>\n<p>As of <a href=\"https:\/\/github.com\/laravel\/framework\/pull\/61343\" rel=\"nofollow noopener\" target=\"_blank\">#61343<\/a>, <code>path()<\/code> runs the argument through <code>WhitespacePathNormalizer<\/code>, the same normalizer that <code>League\\Flysystem\\Filesystem<\/code> 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.<\/p>\n<pre><code class=\"language-php\">\/\/ After 13.30\nStorage::path('reports\/..\/invoices\/2026-08.pdf');\n\/\/ =&gt; &quot;\/var\/www\/app\/storage\/app\/invoices\/2026-08.pdf&quot;  (resolved inside the root, fine)\n\nStorage::path('..\/..\/..\/.env');\n\/\/ throws League\\Flysystem\\PathTraversalDetected\n<\/code><\/pre>\n<p>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&rsquo;t pass request input to <code>path()<\/code>. But &ldquo;a lot of teams&rdquo; isn&rsquo;t &ldquo;all teams&rdquo;, 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 <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-security-audit-with-the-agent-you-already-use\" rel=\"noopener\">running a Laravel security audit with the agent you already use<\/a>. That audit would have flagged the <code>download()<\/code> line. It would not have flagged <code>path()<\/code> itself as the cause, because until this week, <code>path()<\/code> behaving that way was just how it worked.<\/p>\n<h2 id=\"what-this-might-break-on-upgrade\">What this might break on upgrade<\/h2>\n<p>The flip side of a hardening change is that code depending on the old behaviour now throws.<\/p>\n<p>If you have anything that deliberately uses <code>..<\/code> in a path given to <code>Storage::path()<\/code>, it will now either resolve (if the result stays inside the root) or throw (if it doesn&rsquo;t). The first case is silent and probably fine. The second case is an exception in production, and the framework isn&rsquo;t going to guess whether you meant it.<\/p>\n<p>The pattern I&rsquo;ve seen most is a &ldquo;shared&rdquo; folder one level above the disk root that someone reached with <code>Storage::path('..\/shared\/thing.csv')<\/code> 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&rsquo;t enforced.<\/p>\n<p>The other one to watch is tests. If you have a test that asserts <code>Storage::path()<\/code> 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 <code>Storage::path(<\/code> and <code>-&gt;path(<\/code> on a disk before you bump.<\/p>\n<p>My own upgrade checklist for this release, in the order I actually did it:<\/p>\n<ol>\n<li><code>grep -rn \"Storage::path\\|-&gt;path(\" app\/<\/code> and read every hit.<\/li>\n<li>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?<\/li>\n<li>If yes, either validate the path against an allow list before it reaches <code>path()<\/code>, or stop using <code>path()<\/code> there entirely and use <code>download()<\/code> \/ <code>response()<\/code> on the disk, which go through the normalizer.<\/li>\n<li>Run the suite. Anything that throws <code>PathTraversalDetected<\/code> gets fixed, not caught.<\/li>\n<\/ol>\n<p>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&rsquo;d rather reject bad input with a 422 than let it reach the filesystem layer at all.<\/p>\n<h2 id=\"the-small-one-ill-use-every-day-worker-stop-reasons\">The small one I&rsquo;ll use every day: worker stop reasons<\/h2>\n<p>There&rsquo;s a third change in this release that isn&rsquo;t security related and isn&rsquo;t as clever as <code>chunkBy<\/code>, but I suspect it&rsquo;ll save me more time than either. <code>queue:work<\/code> now prints why the worker stopped as its last line of output.<\/p>\n<p>Before, a worker that exited just exited. The <code>WorkerStopping<\/code> event has carried a <code>WorkerStopReason<\/code> 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.<\/p>\n<p>Now:<\/p>\n<pre><code>2026-09-01 13:20:40 Worker STOPPED Memory limit exceeded\n<\/code><\/pre>\n<p>With <code>--json<\/code> it&rsquo;s a structured record, with the reason, exit code, jobs processed and memory alongside the per-job lines. The <code>WorkerStopReason<\/code> enum gained a <code>description()<\/code> method covering nine cases, including memory limit, max jobs, max time, queue empty, restart signal and job timeout. Nothing prints under <code>--quiet<\/code> or <code>--silent<\/code>, so it won&rsquo;t spam a supervisor log.<\/p>\n<p>This connects to something I wrote about in <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-queues-2026-defaults-i-stopped-trusting-in-production\" rel=\"noopener\">the queue defaults I stopped trusting in production<\/a>. A large fraction of &ldquo;why did my worker die&rdquo; investigations end with &ldquo;it hit the memory limit and Horizon restarted it&rdquo;, and until now the evidence for that was indirect. Having the reason in the log line turns a fifteen minute investigation into a glance.<\/p>\n<h2 id=\"was-it-worth-a-whole-post\">Was it worth a whole post?<\/h2>\n<p>I think so, and not because <code>chunkBy<\/code> is exciting. It&rsquo;s because the <code>Storage::path()<\/code> change is the kind of thing that&rsquo;s easy to miss in a weekly release note, and it has a real &ldquo;go check your code&rdquo; action attached to it. Most weeks the answer to &ldquo;should I read the changelog&rdquo; is &ldquo;probably not&rdquo;. This week it was yes.<\/p>\n<p>A few other things in the release worth a glance if you&rsquo;re on SQL Server (native <code>sqlsrv:<\/code> DSN strings now parse instead of getting mangled by <code>parse_url<\/code>), or if you build Artisan tooling (<code>Artisan::commandNamed()<\/code> resolves a single command without constructing all of them). The full list is in the <a href=\"https:\/\/laravel-news.com\/laravel-13-30-0\" rel=\"nofollow noopener\" target=\"_blank\">Laravel News write-up<\/a> and the <a href=\"https:\/\/github.com\/laravel\/framework\/blob\/13.x\/CHANGELOG.md\" rel=\"nofollow noopener\" target=\"_blank\">official changelog<\/a>.<\/p>\n<p>If you want to see how I handle file handling and upgrades on client projects more generally, that&rsquo;s covered on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">work page<\/a>.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Run this in every Laravel project you maintain, even the ones not on 13 yet:<\/p>\n<pre><code class=\"language-bash\">grep -rn &quot;Storage::path\\|Storage::disk([^)]*)-&gt;path&quot; app\/ routes\/\n<\/code><\/pre>\n<p>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&rsquo;t wait for the upgrade to throw the exception for you. Then bump to 13.30, run your suite, and swap your first <code>chunkWhile<\/code> comparison for <code>chunkBy<\/code> while you&rsquo;re in there. That last one is just for fun.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Laravel 13.30 adds chunkBy() for adjacent grouping and finally stops Storage::path() escaping the disk root. What changed, what breaks, and what to grep for.<\/p>\n","protected":false},"author":2,"featured_media":648,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Laravel 13.30 adds chunkBy() for adjacent grouping and finally stops Storage::path() escaping the disk root. What changed, what breaks, and what to grep for.","rank_math_focus_keyword":"laravel collections","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[173,52],"tags":[709,56,710,53,154],"class_list":["post-649","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel","category-php","tag-collections","tag-laravel","tag-laravel-13","tag-php","tag-security"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/649","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=649"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/649\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/648"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=649"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=649"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=649"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}