Skip to content
Back to blog
LaravelSeguridadBuenas prácticas

Laravel 13.27 improves sensitive queries and locking

Three small Laravel 13.27 changes that reduce data leaks in errors and simplify concurrency-sensitive code.

Ismael Catala4 min read

When an exception reveals too much

A database exception can end up in logs, an observability platform, or a failed-jobs table. If its message includes query bindings, it can also carry email addresses, identifiers, personal data, or any other value passed to the query. Laravel 13.27 adds a per-connection option that prevents bindings from being interpolated into QueryException messages. The exact configuration key is mask_bindings_in_exception_messages, and it defaults to false. (raw.githubusercontent.com)

I would enable this option for connections handling real data, especially in production. It does not replace a logging policy, but it removes a common path through which input data reaches systems that should not receive it. Laravel also includes the option in its default database configuration, so it can be controlled with an environment variable. (raw.githubusercontent.com)

// config/database.php
 
'mysql' => [
    'driver' => 'mysql',
    // ...
    'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),
],

With DB_MASK_BINDINGS=true, the failing SQL statement remains visible in the exception message, while values stay as ? placeholders. That keeps enough context to identify the failed statement without automatically printing every binding. It is a small configuration change worth reviewing before the next integrity error reaches an external service. (github.com)

Comparisons that do not depend on collation

Another addition is whereBinary(), available on the query builder. It performs byte-exact comparisons in MySQL and MariaDB without falling back to whereRaw(). Laravel also provides orWhereBinary(), whereNotBinary(), and orWhereNotBinary(). (raw.githubusercontent.com)

This is useful when a text column uses a case-insensitive collation but one particular query must distinguish casing. A technical name, an external key, or an identifier with case-sensitive semantics can be handled explicitly this way. The intent is clearer than embedding SQL, and the value still travels as a bound parameter. (github.com)

$queue = DB::table('queues')
    ->whereBinary('name', $queueName)
    ->first();
 
$otherQueues = DB::table('queues')
    ->whereNotBinary('name', $queueName)
    ->get();

I would not use whereBinary() as a blanket replacement for every where() call. This is a data-semantics decision: you need to know whether Admin, admin, and ADMIN are genuinely different values in the domain. If that rule belongs to the model, validation, constraints, and tests should make the reason for it obvious.

Reloading and locking the right row

refreshForUpdate() addresses a recurring pattern in stock, booking, and shared-resource job code. When an Eloquent instance already exists, the method reloads it from the database and applies lockForUpdate() to that read. The current model instance is updated in place, so there is no need to assign a replacement instance. (raw.githubusercontent.com)

The common case is receiving a model through route model binding and opening a transaction afterwards. Previously, you had to build another primary-key query to get a fresh, locked version of the row. The flow can now look like this. (github.com)

use Illuminate\Support\Facades\DB;
use RuntimeException;
 
DB::transaction(function () use ($product) {
    $product->refreshForUpdate();
 
    if ($product->stock <= 0) {
        throw new RuntimeException('No stock available.');
    }
 
    $product->decrement('stock');
});

The improvement is not that the lock solves every concurrency problem automatically. It removes incidental code around a delicate operation, but the fresh read and the write still need to happen in the same transaction. Laravel recommends wrapping pessimistic locks in a transaction so they are released when the operation completes. (laravel.com)

The fine print

Binding masking does not remove bindings from every possible location. The connection still emits bindings through the QueryExecuted event and may store them in the query log when logging is enabled; APIs that retrieve bindings can still access their values. Review listeners, APM tools, traces, and any custom middleware that records requests or database activity. (raw.githubusercontent.com)

whereBinary() is not a portable abstraction across database engines either. Laravel compiles it for MySQL and MariaDB, while other engines throw a RuntimeException; if an application uses SQLite in tests and MySQL in production, that difference should be tested or isolated behind an explicit decision. (github.com)

There is a less obvious detail with refreshForUpdate(): the reload query runs without global scopes and locates the record by its key. It also uses firstOrFail(), which means it can fail if the row no longer exists when the refresh is attempted. I would not treat it as a convenience-only version of refresh(), but as a concurrency operation that deserves integration tests against the real database engine. (raw.githubusercontent.com)

Laravel 13.27 does not redesign concurrency control or logging governance. It does remove three small sources of fragile code: raw SQL for exact comparisons, manual reloads before locking, and exception messages that expose too much. These are the kind of changes I would apply first where code handles sensitive data or resources with limited availability. (github.com)


Source: Laravel News Official documentation: Laravel Framework v13.27.0