Laravel 13.27 adds a per-connection option that keeps query bindings out of exception messages, a whereBinary() family for byte-exact comparisons on MySQL and MariaDB, a refreshForUpdate() method that reloads an Eloquent model under a pessimistic lock, and more:
mask_bindings_in_exception_messagesleaves?placeholders inQueryExceptionmessageswhereBinary(),orWhereBinary(),whereNotBinary(), andorWhereNotBinary()refreshForUpdate()refreshes a model withlockForUpdate()applied- A
Cloudfacade for Laravel Cloud checks and the managed queue connection - Queue
totalPendingSize(),totalDelayedSize(), andtotalReservedSize() - Vector distance queries now work on MariaDB
- Postgres keepalive DSN options and a shared AWS credential cache for SQS
- Fixes for input handling and validation hardening
What's New
Masking Query Bindings in Exception Messages
QueryException interpolates bindings into its message, so a failing insert puts every bound value into getMessage(). That message then travels wherever exceptions travel: the failed_jobs table, log files, and APM spans. Email addresses, names, etc. are logged.
A new per-connection config key stops the interpolation and leaves the ? placeholders in place:
'mysql' => [ 'driver' => 'mysql', // ... 'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),],
// defaultSQLSTATE[23000]: ... SQL: insert into `users` (`email`) values (foo@example.com)) // maskedSQLSTATE[23000]: ... SQL: insert into `users` (`email`) values (?))
The key ships in the framework's own config/database.php, so applications that never published that file can turn it on with DB_MASK_BINDINGS=true alone. It defaults to false, and only the message changes; getBindings() still returns the values.
Contributed by @LauJosefsen in #61326.
whereBinary() for Case-Sensitive Comparisons
MySQL's default collations compare case insensitively, so where('name', 'John') also matches john and JOHN. Getting a byte-exact comparison meant dropping into raw SQL:
DB::table('queues')->whereRaw('name = BINARY ?', [$queueName])->first();
whereBinary() does the same thing through the query builder, along with orWhereBinary(), whereNotBinary(), and orWhereNotBinary():
DB::table('queues')->whereBinary('name', $queueName)->first();// select * from `queues` where `name` = binary ? DB::table('queues')->whereNotBinary('name', $queueName)->get();// select * from `queues` where `name` != binary ?
MariaDB inherits the MySQL grammar, so it works there too. Postgres, SQLite, and SQL Server throw a RuntimeException, matching how whereLike() handles case-sensitive lookups on engines that already compare case sensitively.
Contributed by @xiCO2k in #61261.
refreshForUpdate()
Models are usually resolved before a transaction starts, through route model binding or a job payload. Taking a pessimistic lock on one meant throwing that instance away and querying again by primary key:
DB::transaction(function () use ($product) { $product = Product::query() ->lockForUpdate() ->findOrFail($product->getKey()); $product->decrement('stock');});
refreshForUpdate() behaves like refresh() with lockForUpdate() applied to the reload query, so the instance you already have is refreshed in place and stays usable for the rest of the transaction:
DB::transaction(function () use ($product) { $product->refreshForUpdate(); if ($product->stock === 0) { throw new RuntimeException('The product is out of stock.'); } $product->decrement('stock');});
Contributed by @stevebauman in #61247. The lock only holds for the life of the transaction, so the call belongs inside one; it closes the data race between reading a model and writing back to it.
A Cloud Facade
Answering "are we on Laravel Cloud, and are we using managed queues?" previously meant combining the laravel_cloud() helper with driver checks. A Cloud facade collects those into three methods:
use Illuminate\Support\Facades\Cloud; Cloud::hosted(); // running on Laravel Cloud?Cloud::usesManagedQueues(); // is the cloud queue connection configured?Cloud::queue(); // the managed queue connection itself
Cloud::queue() throws a RuntimeException when managed queues are not configured, so it pairs with usesManagedQueues() rather than replacing it. The facade is not registered in the default aliases, so it will not collide with an existing Cloud class in an application. The existing Cloud bootstrapping class was renamed to CloudBootstrapper as part of the change.
Contributed by @jackbayliss in #61275. Laravel Cloud also picked up a CLI earlier this year.
Queue Totals Across Every Queue
The existing pendingSize(), delayedSize(), and reservedSize() methods take one queue name, so a dashboard covering several queues had to add them up by hand. Three total counterparts do it in one call:
Queue::totalPendingSize();Queue::totalDelayedSize();Queue::totalReservedSize();
They sum across every queue the connection knows about, and unlike the allXJobs() methods they only count, so no job payloads get decoded. Implemented for the Redis, database, failover, and fake drivers.
Contributed by @jackbayliss in #61231.
Vector Distance Queries on MariaDB
Previously, whereVectorSimilarTo(), whereVectorDistanceLessThan(), orderByVectorDistance(), and selectVectorDistance() were Postgres only, because the driver check was an instanceof PostgresConnection and the pgvector <=> operator was written directly into the query builder.
Both moved to the grammar, following the pattern already used for compileRandom() and supportsSavepoints(). MariaDB 11.7+ has a native VECTOR column type and distance functions, so its grammar compiles to vec_distance_cosine() and the schema-side typeVector() support finally has a query side to match. Plain MySQL still throws, since VECTOR_DISTANCE() only exists on MySQL HeatWave.
Contributed by @Rhaima96 in #61250.
Input Handling and Validation Hardening
@KIKOmanasijev contributed a multipl fixes around how Laravel reads keys and compares values.
Request::merge(['*' => 226]) used to wipe the whole input array instead of adding a * key, because the reducer ran keys through data_set(), which treats * as a wildcard. Uri::withQuery() had the same bug one layer up, turning ?role=user&tenant=10 into ?role=admin&tenant=admin. Both now use Arr::set(), which keeps dot notation working for genuine nested merges but stores * literally (#61309, #61312).
The unknown-field rejection added in Laravel 13 flattened input with Arr::dot(), which produces the same key for a nested profile[name] field and a literal "profile.name" key. The validator escapes literal dots as profile\.name, so the two were comparing keys in different notations: a literal dotted key passed the unknown-field check, then no rule ran against it, and validated() came back empty while the raw value sat in $request->all(). Input keys are now flattened with the same escaping the rule keys use (#61313).
Two validation rules were still using loose comparisons. in_array matched "1e0" against "1" and "0e123456789" against "0", and doesnt_contain failed an array containing "0e123" as though it contained a prohibited "0". Both use strict comparison now (#61319, #61318), following the same fix applied to the in rule earlier. The equivalent change to contains was reverted before the release (#61330).
Finally, MaintenanceModeBypassCookie::isValid() checked that the cookie's mac was set but not that it was a string, so a laravel_maintenance cookie carrying an array handed that array to hash_equals() and turned an expected 503 into an unauthenticated 500. The isset() check became is_string() (#61314).
Other Fixes and Improvements
- Postgres connections accept
keepalives,keepalives_idle,keepalives_interval, andkeepalives_countconfig keys, which reach the DSN instead of being dropped, so a long-lived worker connection is not silently killed by an idle firewall timeout (#61307) - SQS connections can share cached AWS credentials across processes through a
credentials_cacheoption, replacing one credential fetch per PHP-FPM worker with one per rotation. Laravel Cloud enables it by default for managed queues (#61321) orWhereKey()andorWhereKeyNot()are back. The first attempt shipped in 13.26 and was reverted in 13.26.1 for changing thewhereKey()signatures, which fatally broke Builder subclasses that override them. This version delegates through a nested where group and leaves the existing methods untouched (#61242)move()andcopy()on a read-through disk now stream from the fallback when the primary has not promoted the file yet, instead of returningfalsefor a file thatexists()reports as present (#61272)- A debounced job no longer leaves its
maxWaittimestamp behind after running, which made the first dispatch of the next burst skip its debounce entirely (#61281) - A queued job pushed inside a transaction no longer force releases a unique lock it never acquired when the transaction rolls back (#61234)
- Eager-load constraint closures are
staticagain, breaking aBuilderreference cycle that kept builders and their loaded relation trees alive until the cycle collector ran, which matters most in queue workers and Octane (#61264) Process::quietly()->run(...)->throw()throwsProcessFailedExceptionrather than aLogicExceptionabout disabled output (#61227), andFakeInvokedProcessgainedstop()andensureNotTimedOut()so faked processes can be stopped in tests (#61266), both from @xurshudyanInteractsWithTestCaseLifecycle::flushState()gives applications a documented hook for clearing their own static state between tests, instead of overriding an internaltearDownTheTestEnvironment()(#61288)- Images created from a stream no longer fail on the second read, since the lazy loader now resolves once and shares its result with clones.
fromUrl()stops issuing a fresh HTTP request per read for the same reason (#61305), andfromUrl()throws on unsuccessful responses instead of treating a 404 body as image data (#61254) PhpRedisConnection::mget()andhmget()guard against thefalsephpredis returns on a rejected command, which surfaced as aTypeErrorfromarray_map()on a CROSSSLOT error (#61267)- A
Brick\Mathexception no longer escapes the numeric comparison rules. Thegtandgteguards were catching the wrongMathExceptionclass, andltandltehad no guard at all (#61332) Container::scoped()no longer records the same abstract twice in$scopedInstances(#61251),Route::name('x')->post(...)keeps its name for array controller actions (#61285), andmaxRelationshipDepth(0)drops nested includes instead of taking segments from the end of the array (#61297)
References
- Official changelog
- Compare v13.26.1...v13.27.0
- PR: #61326 (masking query bindings)
- PR: #61261 (
whereBinary()) - PR: #61247 (
refreshForUpdate()) - PR: #61275 (
Cloudfacade)