Seu log está guardando o CPF do cliente: mascarando bindings de query no Laravel 13.27

A mensagem da QueryException leva os valores da query para o log e para o APM. Veja como mascarar bindings no Laravel 13.27 e o que fica de fora.

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.