News

Collections chunkBy() and Storage Path Hardening in Laravel 13.30

Published
Collections chunkBy() and Storage Path Hardening in Laravel 13.30 image

Laravel 13.30 adds chunkBy() for grouping adjacent collection items, runs Storage::path() through the same path normalizer every other filesystem call uses, and prints the reason a queue worker stopped directly in queue:work output.

Here's the main features in this week's release:

  • chunkBy() on collections and lazy collections
  • Storage::path() now rejects paths that escape the disk root
  • queue:work writes the worker's stop reason when it exits
  • DevCommands::withoutVendorCommands() and withoutDefaultCommands()
  • Native sqlsrv: DSN connection strings for Microsoft SQL Server
  • Artisan::commandNamed() resolves one command without constructing the rest
  • Cloud queue totalPendingSize(), totalDelayedSize(), and totalReservedSize()
  • route:cache no longer leaves the facades pointing at a throwaway application

What's New

chunkBy() for Collections

The chunkWhile() method splits a collection wherever a callback returns false, and the most common thing people write in that callback is a comparison between the current item and the last item of the chunk being built:

$products->chunkWhile(fn ($value, $key, $chunk) => $value->parent == $chunk->last()->parent);

chunkBy() is that comparison as a method. It takes a key or a callback and starts a new chunk whenever the resolved value changes:

$products->chunkBy('parent');
 
collect([1, 1, 2, 2, 1, 1])->chunkBy(fn ($value) => $value);
// [[1, 1], [2, 2], [1, 1]]

The key goes through data_get(), so dot notation works (chunkBy('address.city')), and original keys are preserved inside each chunk.

Contributed by @JosephSilber in #61357.

Storage::path() Confined to the Disk Root

Every filesystem call except one goes through the Flysystem driver, which normalizes the path first and throws PathTraversalDetected when it resolves outside the disk root. Storage::path() skipped that step and handed the string to Flysystem's PathPrefixer, which does nothing but concatenate:

Storage::get('../../../.env'); // rejected
Storage::path('../../../.env'); // resolved outside the disk

On the default local disk, path() returned a native path pointing at the application's .env while get(), delete(), and readStream() all refused the same argument.

That matters wherever the path comes from user input:

response()->download(Storage::path($request->query('path')));

path() now runs the argument through WhitespacePathNormalizer, the same normalizer League\Flysystem\Filesystem builds for every other call, before prefixing it. Relative segments resolve the way the driver resolves them, and anything that escapes the root throws PathTraversalDetected instead of returning a string. Code that relied on path() accepting .. segments will now throw an exception.

Contributed by @KIKOmanasijev in #61343.

Worker Stop Reasons in queue:work

A worker that exits leaves no trace in the console output of why it went. The WorkerStopping event has carried a WorkerStopReason for a while, but reading it meant registering a listener, which is a lot of setup for a question you usually ask while watching a terminal or scrolling a log.

queue:work now writes the reason as its final line:

2026-09-01 13:20:40 Worker STOPPED Memory limit exceeded

With --json, it goes out as a structured record alongside the per-job output:

{"level":"warning","status":"stopped","reason":"memory","exit_code":12,"jobs_processed":2,"memory":1.2,"timestamp":"2026-09-01T13:20:40.118273+00:00"}

The WorkerStopReason enum gained a description() method for the human-readable strings, covering nine exit scenarios: interrupted, lost connection, maximum jobs exceeded, memory limit exceeded, maximum run time exceeded, queue empty, queue empty for the configured duration, received restart signal, and job timed out. Nothing is written under --quiet or --silent.

Contributed by @jackbayliss in #61339.

Opting Out of Vendor and Default dev Commands

DevCommands registers everything it knows about: the framework defaults (serve, queue:listen, pail, and the Vite script), anything a package in vendor registers, and anything the application registers itself. Narrowing that list meant naming every command you did want through only(), or every command you did not through except(). The first breaks whenever you add a queue, and the second breaks whenever a dependency starts registering something new.

Two methods filter by where the command came from instead:

use Illuminate\Foundation\DevCommands;
 
DevCommands::withoutVendorCommands(); // drop anything registered from vendor/
DevCommands::withoutDefaultCommands(); // drop the framework's own defaults

Both are opt-in and leave application-registered commands alone. DevCommands already tracked the origin of each command as a priority (default, vendor, or userland) to decide which registration wins on a name collision; these methods filter on the same value.

Contributed by @jackbayliss in #61344.

Native SQL Server DSN Connection Strings

Laravel's database URL parser assumed a URL. A native PDO_SQLSRV connection string is not one:

sqlsrv:Server=127.0.0.1,1433;Database=example;Encrypt=true;TrustServerCertificate=true

parse_url() read that whole thing as a path, Laravel stripped the leading character, and the database name came back as erver=127.0.0.1,1433. The result was a connection built from a mangled configuration.

Native sqlsrv: DSNs are now detected before URL parsing and handed to a dedicated parser that pulls out the host, port, database, and the supported PDO_SQLSRV options (Encrypt, TrustServerCertificate, MultiSubnetFailover, ApplicationIntent, LoginTimeout, and the rest), mapping them onto Laravel's connection config keys. Values containing semicolons, closing braces, or surrounding whitespace are escaped when the connection string is rebuilt. Laravel's own sqlsrv://user:pass@host:port/database convention keeps working through the existing URL parser.

Contributed by @HenkPoley in #61341.

Retrieving a Single Artisan Command

Artisan commands resolve lazily, but there was no way to reach one by name without giving that up. Artisan::all() constructs every registered command to build the array you then index into:

// Constructs every command.
$command = Artisan::all()['app:my-command'] ?? null;
 
// Constructs only MyCommand, if it is registered.
$command = Artisan::commandNamed('app:my-command');

The commandNamed() method returns the command instance or null.

Contributed by @timacdonald in #61361.

Cloud Queue Totals

The totalPendingSize(), totalDelayedSize(), and totalReservedSize() methods added in 13.27 return 0 on SQS, because SQS cannot enumerate the queues a connection uses. The Laravel Cloud queue can: it already knows which queues it manages, so the three methods are now implemented there by summing across managedQueues().

use Illuminate\Support\Facades\Cloud;
 
Cloud::queue()->totalReservedSize();

The previous version of that was building the list by hand:

collect(Cloud::queue()->managedQueues())
->sum(fn (string $queue): int => Cloud::queue()->reservedSize($queue));

Contributed by @jackbayliss in #61352.

Other Fixes and Improvements

  • route:cache boots a second, throwaway application to collect a pristine copy of the routes, and bootstrapping it repointed the global facade application at that container. Nothing put it back, so every facade in the process resolved against a discarded application for the rest of the run. Under php artisan optimize, which runs route:cache before the tasks registered through ServiceProvider::$optimizeCommands, a package inspecting routes afterwards hit LogicException: Route is not bound. The command now restores the facade application as soon as the fresh one has bootstrapped (#61346)
  • Request::clamp() returned a 500 for input it could not clamp, so a URL truncated to ?per-page= or typed as ?per-page=foo took down the request instead of falling back. Non-numeric values now fall through to the default, which the min and max then bound (#61355)
  • db:seed --database=mysql switches the default connection and switches it back when the seeder finishes. An exception skipped the restore, leaving the process pointed at the wrong database for everything that ran afterwards. The switch is now wrapped in try/finally (#61354)
  • setVisibility() on a read-through disk only touched the primary, so it returned false and changed nothing for a file that had not been promoted from the fallback yet. It now resolves through readerFor() like getVisibility(), mimeType(), and fileSize() already did (#61375)
  • Queue::createPayloadUsing() went through QueueManager::__call(), which resolves the default connection before forwarding. Registering the callback in a service provider on an application whose default connection is not yet configured threw The [cloud] queue connection has not been configured. The method is defined on the manager directly now, since the callback is static and does not need a connection (#61367)
  • Tooltips on the exception page render with allowHTML: false by default. The framework's own syntax-highlighted source tooltip opts back in through a separate data-tippy-html-content attribute (#61381)
  • JSON:API resources track the relationships requested relative to each included resource, so nested include paths resolve against the right level instead of eager loading from the top-level request (#61323)
  • spl_object_id() replaces spl_object_hash() in the container, the Eloquent builder, global scopes, and Onceable, backported from 14.x to clear deprecation warnings in userland (#61372)
  • Handler::contextForException() exposes the exception log context publicly, and BladeMapper::findCompiledView() and detectLineNumber() became public (#61362)
  • The Cloud events transport returns a boolean from emit() and emitMany() rather than swallowing failures, gained a $socketFactory hook for testing, and is registered during exception-handler bootstrapping rather than only when managed queues boot (#61360)
  • The quadratic wildcard rule expansion fix in ValidationRuleParser::explodeWildcardRules() was backported to 12.x, which stops the accumulated rule set being copied on every expansion over a large array (#61232)

References

Paul Redmond photo

Staff writer at Laravel News. Full stack web developer and author.

Filed in

Sponsored

acquaintsoft logo
Acquaint Softtech

Hire Laravel developers with AI expertise at $20/hr. Get started in 48 hours.

Visit Acquaint Softtech

The latest

View all →
Forte: Parse and Rewrite Laravel Blade Templates image

Forte: Parse and Rewrite Laravel Blade Templates

Read article
Compoships: Eloquent Relationships on Multiple Columns image

Compoships: Eloquent Relationships on Multiple Columns

Read article
MKSine: A Filament CMS with Plugins, Themes, and Blocks image

MKSine: A Filament CMS with Plugins, Themes, and Blocks

Read article
Cancel In-Flight Form Submissions in Inertia.js v3.7 image

Cancel In-Flight Form Submissions in Inertia.js v3.7

Read article
Laravel Starter Kits Now Ship with Vite+ image

Laravel Starter Kits Now Ship with Vite+

Read article
Pessimistic Locking in Laravel Eloquent with refreshForUpdate() image

Pessimistic Locking in Laravel Eloquent with refreshForUpdate()

Read article