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 collectionsStorage::path()now rejects paths that escape the disk rootqueue:workwrites the worker's stop reason when it exitsDevCommands::withoutVendorCommands()andwithoutDefaultCommands()- Native
sqlsrv:DSN connection strings for Microsoft SQL Server Artisan::commandNamed()resolves one command without constructing the rest- Cloud queue
totalPendingSize(),totalDelayedSize(), andtotalReservedSize() route:cacheno 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'); // rejectedStorage::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:cacheboots 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. Underphp artisan optimize, which runsroute:cachebefore the tasks registered throughServiceProvider::$optimizeCommands, a package inspecting routes afterwards hitLogicException: 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=footook 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=mysqlswitches 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 intry/finally(#61354)setVisibility()on a read-through disk only touched the primary, so it returnedfalseand changed nothing for a file that had not been promoted from the fallback yet. It now resolves throughreaderFor()likegetVisibility(),mimeType(), andfileSize()already did (#61375)Queue::createPayloadUsing()went throughQueueManager::__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 threwThe [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: falseby default. The framework's own syntax-highlighted source tooltip opts back in through a separatedata-tippy-html-contentattribute (#61381) - JSON:API resources track the relationships requested relative to each included resource, so nested
includepaths resolve against the right level instead of eager loading from the top-level request (#61323) spl_object_id()replacesspl_object_hash()in the container, the Eloquent builder, global scopes, andOnceable, backported from 14.x to clear deprecation warnings in userland (#61372)Handler::contextForException()exposes the exception log context publicly, andBladeMapper::findCompiledView()anddetectLineNumber()became public (#61362)- The Cloud events transport returns a boolean from
emit()andemitMany()rather than swallowing failures, gained a$socketFactoryhook 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
- Official changelog
- Compare v13.29.0...v13.30.0
- PR: #61357 (
chunkBy()) - PR: #61343 (
Storage::path()normalization) - PR: #61339 (worker stop reasons)
- PR: #61344 (
DevCommandsopt-outs) - PR: #61341 (SQL Server DSNs)