News

Query Binding Masking and whereBinary() in Laravel 13.27

Published
Query Binding Masking and whereBinary() in Laravel 13.27 image

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_messages leaves ? placeholders in QueryException messages
  • whereBinary(), orWhereBinary(), whereNotBinary(), and orWhereNotBinary()
  • refreshForUpdate() refreshes a model with lockForUpdate() applied
  • A Cloud facade for Laravel Cloud checks and the managed queue connection
  • Queue totalPendingSize(), totalDelayedSize(), and totalReservedSize()
  • 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),
],
// default
SQLSTATE[23000]: ... SQL: insert into `users` (`email`) values (foo@example.com))
 
// masked
SQLSTATE[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, and keepalives_count config 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_cache option, replacing one credential fetch per PHP-FPM worker with one per rotation. Laravel Cloud enables it by default for managed queues (#61321)
  • orWhereKey() and orWhereKeyNot() are back. The first attempt shipped in 13.26 and was reverted in 13.26.1 for changing the whereKey() 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() and copy() on a read-through disk now stream from the fallback when the primary has not promoted the file yet, instead of returning false for a file that exists() reports as present (#61272)
  • A debounced job no longer leaves its maxWait timestamp 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 static again, breaking a Builder reference 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() throws ProcessFailedException rather than a LogicException about disabled output (#61227), and FakeInvokedProcess gained stop() and ensureNotTimedOut() so faked processes can be stopped in tests (#61266), both from @xurshudyan
  • InteractsWithTestCaseLifecycle::flushState() gives applications a documented hook for clearing their own static state between tests, instead of overriding an internal tearDownTheTestEnvironment() (#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), and fromUrl() throws on unsuccessful responses instead of treating a 404 body as image data (#61254)
  • PhpRedisConnection::mget() and hmget() guard against the false phpredis returns on a rejected command, which surfaced as a TypeError from array_map() on a CROSSSLOT error (#61267)
  • A Brick\Math exception no longer escapes the numeric comparison rules. The gt and gte guards were catching the wrong MathException class, and lt and lte had 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), and maxRelationshipDepth(0) drops nested includes instead of taking segments from the end of the array (#61297)

References

Paul Redmond photo

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

Sponsored

serpapi logo
SerpApi

The Web Search API for Your LLM and AI Applications

Visit SerpApi

The latest

View all →
State of Laravel 2026 Survey Is Now Open image

State of Laravel 2026 Survey Is Now Open

Read article
Testing Best Practices Skill in Laravel Boost v2.6.0 image

Testing Best Practices Skill in Laravel Boost v2.6.0

Read article
Laravel AI: Load Tools On Demand With ToolSearch image

Laravel AI: Load Tools On Demand With ToolSearch

Read article
Laravel Auditor Audits Your App With Your Own AI Agent image

Laravel Auditor Audits Your App With Your Own AI Agent

Read article
A simple form builder that stays out of your way image

A simple form builder that stays out of your way

Read article
Laravel AI: Trace Agent Runs With Lifecycle Events image

Laravel AI: Trace Agent Runs With Lifecycle Events

Read article