Originally published at hafiz.dev
Here is something most Laravel developers have never noticed. When a database query fails, Laravel takes the SQL, fills in the real values, and puts the whole thing into the error message. Every value. The email someone just typed into your signup form, the name, the password hash, the API token you were saving. That message then goes wherever your errors go, and it stays there.
I'd been writing Laravel for years before I looked at this properly. Laravel 13.27, released on 26 August 2026, adds a one-line switch that turns it off. This post explains what the leak looks like, why the switch doesn't work the way the announcement suggests, and how to find and delete what's already in your logs.
What a failed query actually writes
Let me show you rather than describe it. A fresh Laravel 13.29 app, SQLite, the default users table with its unique index on email. Create a user, then try to create the same one again:
User::create(['name' => 'Mario Rossi', 'email' => 'mario.rossi@example.com', 'password' => 'secret-password']);
User::create(['name' => 'Mario Rossi', 'email' => 'mario.rossi@example.com', 'password' => 'secret-password']);
The second call throws a QueryException (a UniqueConstraintViolationException, to be exact). This is its getMessage(), straight from the terminal:
SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email
(Connection: sqlite, Database: /var/www/app/database/database.sqlite,
SQL: insert into "users" ("name", "email", "password", "updated_at", "created_at")
values (Mario Rossi, mario.rossi@example.com, $2y$12$hR9..., 2026-08-29 08:57:42, 2026-08-29 08:57:42))
The query you wrote had five ? placeholders. The message has the five real values in their place. Laravel does this on purpose, because a message with the values in it is much easier to debug. That's true. It's also a copy of your user's personal data, in plain text, inside an error string.
Where that string ends up
An exception message doesn't stay in memory. It gets written down, usually in more places than you think.
storage/logs/laravel.log. If the exception isn't caught, the handler logs it. In my test the log line was local.ERROR: SQLSTATE[23000] ... followed by the full message, email included. Log files get rotated, backed up, rsynced to other servers, and sometimes shipped to a logging service. Each copy carries the data.
The failed_jobs table. This one surprised me. The exception column is a longText that stores the whole exception chain as a string. I dispatched a job that hit the same unique index, ran queue:work --once, and queried the table:
DB::table('failed_jobs')
->where('exception', 'like', '%mario.rossi@example.com%')
->count(); // 1
The row contains the PDOException, then "Next Illuminate\Database\UniqueConstraintViolationException" with the full SQL and every value. Failed jobs are kept for 24 hours by default if you prune them, and forever if you don't. Most apps don't.
Error trackers. Sentry, Bugsnag, Flare, Nightwatch. They all receive the exception message as the headline of the event. Sentry's default server-side scrubbing removes values in fields named password, secret, token and so on, and anything that looks like a credit card number. An email address sitting in the middle of a SQL string in a free-text message is not on that list. So it goes through, and it sits in a third party's database under their retention policy.
Telescope, Slack alerts, email notifications. Anywhere you've wired exceptions to go.
None of this is a bug. It's the default behaviour doing exactly what it says. But if someone asks you "where is customer data stored?" and your answer doesn't include "the error log and the failed_jobs table", the answer is incomplete. If you deal with GDPR, personal data with no retention limit in a log file is exactly the kind of thing an audit finds.
The switch in Laravel 13.27
Laravel 13.27 adds a per-connection config key, contributed by Lau Josefsen in PR #61326:
'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),
With it on, the same failure produces this message:
SQLSTATE[23000]: Integrity constraint violation: 19 UNIQUE constraint failed: users.email
(Connection: sqlite, Database: /var/www/app/database/database.sqlite,
SQL: insert into "users" ("name", "email", "password", "updated_at", "created_at")
values (?, ?, ?, ?, ?))
The placeholders stay as placeholders. Nothing else changes. The query still fails the same way, the exception is the same class, and the values are still available on the exception object if you need them (more on that below). Only the message is different.
It's off by default, so you won't get it unless you turn it on.
The part the announcement gets slightly wrong
The release notes say the key ships in the framework's own config/database.php, so apps can enable it with DB_MASK_BINDINGS=true in .env and nothing else. I tried exactly that on a fresh app and it did nothing. The message still had the values in it.
The reason is that every Laravel app has its own published config/database.php, and that file doesn't contain the new key. Laravel does merge your config with the framework's defaults, and for database it even merges the connections list. But each connection you define replaces the framework's version of that connection wholesale. Your mysql array wins over the framework's mysql array, and yours doesn't have the key. So config('database.connections.sqlite.mask_bindings_in_exception_messages') came back null, and null means off.
The fix is to add the line to each connection you use:
'mysql' => [
'driver' => 'mysql',
'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),
// ...
],
Then set DB_MASK_BINDINGS=true in .env, clear the config cache, and it works. I verified this by checking config() before and after. If you skip the config edit, the env var is silently ignored, which is the worst kind of security setting: one that looks on and isn't.
Cleaning up what's already there
Turning on masking only changes exceptions from now on. Everything that failed before today is still written down. Three places to look.
Failed jobs. Count how many rows contain something that looks like an email:
DB::table('failed_jobs')
->where('exception', 'like', '%@%.%')
->count();
Then decide. If those jobs are old and you're never going to retry them, delete them all with php artisan queue:flush. If some are worth keeping, prune by age instead: php artisan queue:prune-failed --hours=48. And put that prune command on the scheduler so the table stops being a permanent archive.
Log files. A quick search over whatever is still on disk:
grep -cE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' storage/logs/*.log
That counts lines with an email-shaped string per file. Delete the old files or let rotation do it. If you're still on the single log channel, switch to daily and set LOG_DAILY_DAYS to something short. The default is 14. And check where else those files went: server backups, a log shipper, a colleague's laptop after a debugging session.
Error tracker. Search your Sentry or Bugsnag project for insert into and @. You can delete individual events and set a shorter retention window. For anything sensitive that went through, the honest step is to treat it as a small incident and note it, because the data left your servers.
What you lose, and how to get it back
Masked messages are harder to debug from a log line alone. When a job fails at 2am and the log says values (?, ?, ?), you can't see which row caused it. That's a real cost, and it's why the setting is off by default.
You don't lose the data, though. The QueryException object still carries everything:
try {
$user->save();
} catch (QueryException $e) {
$e->getSql(); // the query with ? placeholders
$e->getBindings(); // the real values, as an array
$e->getRawSql(); // the query with values filled in, properly quoted
}
So the pattern is to keep the values out of the places that persist for a long time and are hard to control (log files, failed_jobs, third-party trackers), and reach for getBindings() in the places where a person is actively debugging, behind authentication. Telescope, Pulse or Nightwatch are the right home for that kind of detail, because they sit behind your login and you control their retention. A log file on disk doesn't have either.
For queue jobs specifically, log the identifier rather than the payload. A failed() method on the job that writes "ImportUser failed for row 4812" tells you what to look at without copying the row into the exception column. If you've read my post on processing 10,000 queued tasks without breaking, this is the same idea from a different angle: the job should carry an ID, not the data.
FAQ
Does this affect Laravel 12 or older?
No. The config key exists from Laravel 13.27 onwards. On older versions the values are always interpolated. If you're on 12 and can't upgrade yet, the practical options are to catch QueryException where personal data flows through, rethrow with a cleaner message, and prune failed_jobs aggressively.
Will masking change how my error tracker groups events?
No. Sentry, for example, groups an exception by its stack trace when one is present, and only falls back to the message text when it has nothing better. Two QueryExceptions from the same line group together whether the message contains real values or placeholders. What changes is the title you see on the issue, and a title with (?, ?, ?) in it is the one you want on a screen other people can see.
Does it hide the values from dd() or the debug page in local development?
No. Only the exception message changes. The debug page shows the exception object, and getBindings() still returns the array. Local debugging is unaffected. You can also leave DB_MASK_BINDINGS=false in your local .env and set it to true only in production.
Is this a GDPR requirement?
GDPR doesn't name log files, but it does require that personal data isn't kept longer than needed and is protected appropriately. A log file with no retention limit, copied to backups, containing emails and names, is hard to defend on either point. Masking plus a short log retention is a cheap way to close the gap.
The one line, and the second one
Add 'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false) to every connection in config/database.php, set DB_MASK_BINDINGS=true in production, and clear the config cache. Then spend ten minutes on queue:flush and your old log files. The switch stops the leak going forward. The cleanup is the part that actually removes the data, and it's the part people skip.
Top comments (0)