Here's the kind of query failure that happens on any given Tuesday: a duplicate email hits a unique constraint on a users table. Nothing dramatic. But the exception Laravel writes for it looks like this:

insert into users (email, name, national_id) values ('[email protected]', 'Ada Lovelace', '640312-4185')

That's not a sanitized example. That's the actual row, name and national ID sitting right there in the error string, because that's just what QueryException does by default. It fills the placeholders back in with real values and hands you the whole thing.

There's a reasonable argument for that. values (?, ?, ?) tells you a query failed. The real values tell you which customer, which record, why. At 2am with an alert going off, that's the version you actually want. Laravel News wrote up the fix after it landed in 13.27, and it's worth reading, because it spells out something we hadn't fully thought through either: that message doesn't stay attached to the exception object. It gets written down, more than once, in places nobody's checking.

Where that message actually goes

Start with the queue. If a job fails and lands in failed_jobs, DatabaseFailedJobProvider::log() casts the whole exception to a string and stores it, bindings included, in a column with no retention policy and no masking. Add an APM agent or OpenTelemetry and it recorded that same string on the span the second it fired. Forward exceptions to Sentry, Bugsnag, or something like Nightwatch, and now a third party has it too, on their own retention schedule, fully indexed and searchable. None of those tools did anything wrong. They logged exactly what Laravel handed them.

What's at risk depends on your schema

What's actually sitting in there depends on your schema, and that's where this stops being abstract. A duplicate email is a shrug. A duplicate entry on a table that also carries a national ID, a card token reference, or an API key starts touching PCI scope and breach notification law fairly fast. And the realistic failure mode here isn't a hacker. It's a support engineer running tail -f on a file that was never supposed to hold that kind of information.

The fix is one line

The fix, once you know it exists, is one line. Set mask_bindings_in_exception_messages on a connection:

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

The key is already sitting in Laravel's own config/database.php on all five default connections, so if your app has never published that file, DB_MASK_BINDINGS=true in .env is the entire change. The message keeps its ? placeholders. You give up a little convenience at debug time and get logs that aren't also a liability.

What bugs us about this one is the timing. The setting defaults to off. Every Laravel app that hasn't set it has been writing bound values into plaintext logs since its first migration, and nobody got a warning, because nothing about it ever looked broken. It still doesn't look broken. That's exactly what makes it easy to skip.

Flipping the flag doesn't clean up the past

Flipping the flag doesn't touch what's already sitting there, either. Months of failed_jobs rows. Error tracker events parked in someone else's retention window. Log files that rotate on schedule but never quite get deleted. That's not a config change anymore, that's an audit, and audits are the first thing that gets pushed to "next sprint" because nothing's on fire.

We've written before about keeping guardrails in code instead of a prompt, and this is the same problem wearing a different hat: a setting nobody remembers to turn on isn't a control, it's a hope. Same story with background jobs that outlive a deploy. The part of your system nobody's watching is usually the part worth checking first.

An audit worth doing this week

If you maintain a Laravel app and can't answer, off the top of your head, whether this is set or what's currently sitting in your failed_jobs table, that's about half a day of work. It's the unglamorous kind of thing ongoing maintenance is actually for. Cheaper to find on a random Tuesday than in a breach notification letter.