Your failed_jobs Table Is Logging Customer Emails: Binding Masking in Laravel 13.27
Laravel 13.27 adds a per-connection option that keeps query bindings out of QueryException messages, your logs, and your failed_jobs table.
Go look at your failed_jobs table. Pick a row where the job blew up on a database write and read the exception column. If a signup job hit a unique constraint, that column contains the subscriber’s email address in plain text, sitting in your database until somebody truncates the table.
This is not a bug. It is what QueryException has always done. Here is the line responsible, quoted from the pull request that finally changed it:
return $previous->getMessage().' (Connection: '.$connectionName.$details.', SQL: '
.Str::replaceArray('?', $bindings, $sql).')';
Every value bound to the failing query gets interpolated into the message. Laravel 13.27, released August 26, 2026, adds a per-connection switch to stop that.
Where the message actually ends up
The reason this matters more than a stray log line is how far an exception message travels once it exists. Lau Josefsen, who wrote the patch, called out three destinations in PR #61326:
DatabaseFailedJobProvider::log()writes(string) $exceptionstraight intofailed_jobs.exception- Log files, wherever your channel stack sends them, including third-party log aggregators
- APM and OpenTelemetry instrumentation, which records exceptions at the span level
His framing is the one that should get your attention: your exception handling and logging setup inherits the data policies of your database. If you are careful about who can query users.email and completely relaxed about who can read production logs, those two policies are now the same policy, and the weaker one wins.
It gets slightly worse. The message also carries the connection host, port, and database name, so a single failed insert hands a reader both the PII and a rough map of where it lives.
The switch
One key, per connection:
'connections' => [
'mysql' => [
'driver' => 'mysql',
// ...
'mask_bindings_on_exception_message' => true,
],
],
The before and after, from the PR description:
// false (default, unchanged)
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry (Connection: mysql,
Host: 10.0.4.17, Port: 3306, Database: platform, SQL: insert into `users` (`email`)
values ([email protected]))
// true
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry (Connection: mysql,
Host: 10.0.4.17, Port: 3306, Database: platform, SQL: insert into `users` (`email`)
values (?))
You keep the SQLSTATE code, the driver’s own error text, the statement shape, and the connection details. You lose the values, which is the entire point.
The key ships in the framework’s own config/database.php, so if you have never published that file you do not have to. One environment variable is enough:
DB_MASK_BINDINGS=true
If you have published config/database.php, which most non-trivial applications have, you need to add the key yourself to every connection you care about. Do not forget the second connection you added for reporting, or the tenant connections you build at runtime.
What does not change
Masking only touches getMessage(). The values are still on the exception object:
try {
DB::table('subscribers')->insert([
'email' => $email,
'plan' => 'phparch-annual',
]);
} catch (QueryException $e) {
Log::error($e->getMessage()); // masked, safe to ship anywhere
report_to_secure_channel([
'sql' => $e->getSql(),
'bindings' => $e->getBindings(), // still the real values
]);
}
The commit message is explicit that getBindings(), getSql(), and getRawSql() are unaffected, so the values remain available on demand.
That last one is the caveat worth writing on a sticky note. If you have a custom exception renderer or a Sentry before_send hook that calls getRawSql() to build a nicer report, masking the message does nothing for you. getRawSql() interpolates the bindings the same way formatMessage() used to. Turning the config key on and leaving that hook in place gives you a clean log file and a fully populated APM payload, which is arguably worse than before because now you think you fixed it. Grep for getRawSql before you call the job done.
Why it is per connection, and why it is all or nothing
Two design decisions in the PR are worth understanding, because both will look like limitations until you read the reasoning.
Per connection, not per application. Illuminate\Database is usable standalone through Capsule and has no container access. A Connection object can only be configured through the $config array it is constructed with, so the connection config is the only place the setting can live.
No per-binding sensitivity markers. During review, shaedrich proposed something more surgical, along the lines of wrapping individual values so only they get replaced. Josefsen’s answer is the interesting part. Bindings arrive at the connection through a lot of abstraction layers, and validation is the obvious one:
$request->validate([
'email' => 'required|email|unique:subscribers,email',
]);
That lands in DatabasePresenceVerifier as $this->table($collection)->where($column, '=', $value). There is no seam there for the application to mark $value as sensitive. He also made the argument that if you were going to build a marker system, it would have to default to masked with an explicit opt out, because anything defaulting to revealing means every forgotten marker is a leak. That is the right instinct, and it is a much larger change than one config key.
The four-year version of this story
This request is older than most of the code in your app.
Discussion #41920 asked for exactly this in 2022 and got no replies. PR #54203 proposed it again in January 2025, got useful feedback on naming, went to draft, and was closed in July 2025 for inactivity. PR #61326 landed August 25, 2026, and deliberately reused the naming that the 2025 thread had settled on.
The thing that got it merged was not a new idea. It was a PR description that named the exact code path, listed the three places the message escapes to, explained why a userland fix is incomplete, and shipped with the default set to false so nobody’s behaviour changed. Worth remembering the next time you have a feature request sitting in a discussion thread.
Two more from 13.27 worth a look
whereBinary() gives you byte-exact comparisons through the query builder on MySQL and MariaDB, replacing whereRaw('name = BINARY ?', [$name]). It comes with orWhereBinary(), whereNotBinary(), and orWhereNotBinary(). Postgres, SQLite, and SQL Server throw a RuntimeException, since they already compare case sensitively.
DB::table('queues')->whereBinary('name', $queueName)->first();
// select * from `queues` where `name` = binary ?
refreshForUpdate() behaves like refresh() with lockForUpdate() applied to the reload query, so a model you already have from route model binding or a job payload can take a pessimistic lock in place instead of being re-fetched by primary key. Call it inside a transaction, since that is the only place the lock holds.
Do this today
Turn DB_MASK_BINDINGS=true on in production and staging, leave it off locally where interpolated values genuinely help. Then grep for getRawSql() in your exception handling. Then go clear out failed_jobs.
Sources
- Query Binding Masking and whereBinary() in Laravel 13.27 — Paul Redmond, Laravel News, August 26, 2026
- [13.x] Allow masking query bindings in exception messages — laravel/framework PR #61326
- Add QueryException message handling without replacing bindings — laravel/framework PR #54203
- QueryException shouldn’t print the SQL in envs where debugging is not desired — laravel/framework discussion #41920
- Laravel framework releases — github.com/laravel/framework
- Database: Query Builder — Laravel 13.x documentation