Um insert falhou por causa de um índice único. O Laravel levantou uma QueryException, o handler reportou o erro, e trinta segundos depois a mensagem apareceu inteira no canal do time. Junto com ela foram o e-mail, o nome e o documento do cliente que estava tentando se cadastrar.
Ninguém escreveu essa linha de log. Ela nasce do próprio framework, e vem assim desde sempre. Se a sua aplicação manda exceção para uma ferramenta de observabilidade, o dado do seu cliente já saiu do seu perímetro hoje de manhã, e provavelmente ninguém percebeu.
Por que a mensagem sai com os valores dentro
Quando uma query falha, o Laravel monta a mensagem da exceção juntando o erro do driver, o nome da conexão e o SQL. E ele não guarda o SQL com os ? no lugar: ele substitui cada placeholder pelo valor que foi enviado.
O código é curto e está em Illuminate\Database\QueryException:
$sql = $maskBindings ? $sql : Str::replaceArray('?', $bindings, $sql);
return $previous->getMessage()
.' (Connection: '.$connectionName.$details.', SQL: '.$sql.')';
Isso foi feito pensando em quem depura. Ler values (?, ?, ?) não ajuda ninguém a entender por que o banco recusou a linha, enquanto ler o valor real resolve o problema em segundos.
O detalhe é que a mensagem de uma exceção não fica onde você a criou. Ela é uma string, e string de exceção vai para arquivo de log, para o Slack, para o e-mail de erro e para o APM.
O problema não é a exceção, é o trajeto dela
Pense no caminho completo. Um insert em uma tabela de clientes falha, o report() empacota a exceção, o driver de log grava, o serviço externo indexa e guarda por meses.
Agora repita isso alguns milhares de vezes por semana, que é a ordem de grandeza de um sistema de varejo com app mobile e API em PHP recebendo tráfego real. Cada exceção de banco que passa por ali é uma linha com dado pessoal dentro.
É assim que dados sensíveis no log do Laravel deixam de ser hipótese e viram rotina, sem nenhuma linha de código escrita para isso.
O caso fica mais desconfortável quando o dado é fiscal. Quem emite documento eletrônico lida com CPF, CNPJ, razão social e endereço o dia inteiro, e esse tipo de payload aparece em campo de where e de insert o tempo todo. Se você já lidou com o XML da NF-e e os campos novos de IBS/CBS, sabe exatamente o volume de dado identificável que circula nessas tabelas.
A comparação que costuma acordar o time é simples. Você nunca logaria a senha do usuário de propósito, mas está logando o documento dele sem querer, por um comportamento padrão do framework.
A solução que chegou no 13.27
Em 25 de agosto de 2026 foi mesclado no laravel/framework o PR #61326, que entrou no Laravel 13.27. Ele adiciona uma opção por conexão para manter os bindings fora da mensagem da exceção.
No branch 13.x, a chave aparece nas cinco conexões padrão do config/database.php, sempre no mesmo formato:
'mysql' => [
'driver' => 'mysql',
// ...
'mask_bindings_in_exception_messages' => env('DB_MASK_BINDINGS', false),
],
Repare no valor padrão: false. Atualizar o framework não protege ninguém sozinho. Sem uma decisão sua, a mensagem continua saindo com os valores interpolados.
Ligando em uma linha
Como a chave já vem no arquivo de configuração distribuído com o framework, na maioria dos projetos você liga tudo por variável de ambiente:
DB_MASK_BINDINGS=true
Se o seu projeto publicou o config/database.php há tempo e a chave não está lá, adicione manualmente na conexão que interessa. Fazer isso por conexão é útil quando você tem um banco com dado pessoal e outro só com dado operacional.
O que muda na prática
Antes, com a opção desligada, a mensagem carrega os valores:
SQL: insert into `users` (`email`, `name`, `national_id`) values (ada@example.com, Ada Lovelace, 640312-4185)
Depois, com o mascaramento ligado, ela mantém a forma da query e descarta o conteúdo:
SQL: insert into `users` (`email`, `name`, `national_id`) values (?, ?, ?)
Você continua sabendo qual tabela, quais colunas e qual tipo de comando falhou. Perde apenas a parte que não deveria estar no log.
O debug não morre junto
Essa é a parte que costuma travar a adoção, e ela já foi resolvida no desenho do recurso. O mascaramento age na mensagem formatada, não nos dados da exceção.
Segundo o PR, getBindings(), getSql() e getRawSql() continuam funcionando como antes. Ou seja, quando você precisa mesmo do valor para investigar, ele está a uma chamada de distância:
try {
DB::table('clientes')->insert($dados);
} catch (QueryException $e) {
report($e); // mensagem sem os valores, segura para log e APM
// Use apenas em investigação local e consciente:
// dump($e->getBindings());
}
Um aviso honesto sobre esse bloco. Se você chamar getBindings() dentro de um logger() em produção, acabou de reabrir o buraco que fechou. A opção protege o caminho automático, não protege você de si mesmo.
Cuidados que o recurso não cobre
Ligar a flag resolve um canal específico. Existem outros, e vale auditar todos na mesma sentada.
O mais comum é o listener de query. A própria documentação do Laravel mostra o DB::listen() com toRawSql() disponível no evento, e esse método volta a interpolar os valores:
DB::listen(function (QueryExecuted $query) {
// $query->sql; // com placeholders
// $query->bindings; // os valores, crus
// $query->toRawSql(); // interpolado de novo
});
Se o seu projeto tem um listener assim gravando query lenta em log, o mascaramento da exceção não vai te salvar. A responsabilidade ali é sua.
Além dele, olhe para três lugares:
- Contexto de log. Se você passa o payload da request no contexto do Monolog, o dado vai junto, independente da exceção.
- Payload de job. Job enfileirado guarda os argumentos serializados, e uma falha de job costuma levar esse payload para o relatório de erro.
- Outras exceções. A opção cobre
QueryException. Uma exceção de validação, de HTTP client ou de integração continua carregando o que você colocou nela.
Aqui vale a mesma postura defensiva que se usa ao tratar payload de API externa: não confie no comportamento padrão, confirme o que de fato sai da sua aplicação.
Limitações
Três limites concretos para você decidir com informação.
O recurso exige Laravel 13.27 ou superior. Em versões anteriores não existe a opção, e o caminho é interceptar o report da exceção ou tratar o log antes de enviar, o que dá bem mais trabalho.
A opção também não aparece na página de documentação de banco de dados do 13.x, pelo menos até a consulta feita em 10 de setembro de 2026. Hoje a fonte confiável é o próprio config/database.php do framework e o PR que a introduziu.
E tem uma armadilha de nome. A descrição do PR usa mask_bindings_on_exception_message, no singular, enquanto o código no branch 13.x usa mask_bindings_in_exception_messages. Vale o código. Se copiar do texto do PR, a configuração simplesmente não pega e você vai achar que o recurso não funciona.
Perguntas frequentes
A partir de qual versão do Laravel isso existe?
Laravel 13.27. O PR #61326 foi mesclado em 25 de agosto de 2026 e entrou nessa versão.
Qual é o nome exato da configuração?
mask_bindings_in_exception_messages, definida por conexão em config/database.php, com a variável de ambiente DB_MASK_BINDINGS. O padrão é false.
Ligar isso atrapalha o debug de erro em produção?
Não precisa atrapalhar. O mascaramento afeta a mensagem formatada da exceção, e os métodos getBindings(), getSql() e getRawSql() continuam acessíveis quando você realmente precisar do valor.
Isso resolve o vazamento de dado no log de forma completa?
Não. Cobre a mensagem da QueryException. Listener de query com toRawSql(), contexto de log, payload de job e outras exceções continuam sob sua responsabilidade.
Preciso publicar o config/database.php para usar?
Não necessariamente. A chave já consta no arquivo distribuído com o framework nas cinco conexões padrão, então na maioria dos projetos basta definir DB_MASK_BINDINGS=true. Se o seu projeto publicou o arquivo antes dessa versão, adicione a chave manualmente.


