display_errors em produção: esconda o erro do PHP sem perder o diagnóstico
Desligue a exibição de erros no lugar certo (php.ini, pool do FPM ou .user.ini), mande tudo para o log com um código de ocorrência e confirme no servidor que a configuração pegou.
Um cliente manda o print: no topo da página de produtos aparece Fatal error: Uncaught mysqli_sql_exception: Access denied for user 'loja'@'localhost' (using password: YES) in /home/casapinho/public_html/inc/conexao.php:4. Numa linha, o visitante aprendeu o usuário do banco, o caminho do servidor e o nome do arquivo de conexão. A resposta curta: em produção, display_errors fica desligado e log_errors ligado, configurados no servidor e não no código; a pessoa vê uma frase com um código, e você encontra o detalhe no log por esse código.
O princípio de que a pilha não vai para a tela está no guia sobre erro 500 com frase e código. Este é o lado específico do PHP: quais diretivas, onde colocá-las para que valham de verdade e como provar que valeram.
Por que o erro aparece na página
O PHP tem duas saídas para um erro: a resposta HTTP e o log. A diretiva display_errors controla a primeira, log_errors a segunda, e error_reporting decide quais níveis contam. Pacotes de desenvolvimento como XAMPP vêm com exibição ligada, porque é cômodo ver o aviso na hora. O problema começa quando o projeto sobe com um ini_set('display_errors', '1') esquecido no topo, ou quando o servidor usa um php.ini de desenvolvimento.
Há um detalhe que pega muita gente: configurar no código não basta. Um erro de sintaxe acontece antes de qualquer linha do arquivo rodar, então o ini_set nunca é executado. E um warning num arquivo incluído antes do seu ini_set já foi impresso. A configuração precisa estar no servidor.
A configuração de produção
Estas são as diretivas que importam. Os valores valem para qualquer servidor de produção:
display_errors = Off
display_startup_errors = Off
log_errors = On
error_reporting = E_ALL
error_log = /var/log/php/casapinho-error.log
expose_php = Off
O error_reporting = E_ALL parece contraditório, mas não é: você quer que todos os erros sejam registrados, só não exibidos. Desligar os avisos para "limpar a tela" esconde também do log o warning que avisaria do problema antes de ele virar queda. O expose_php = Off remove o cabeçalho X-Powered-By, que anuncia a versão do PHP a quem pergunta.
Onde colocar, conforme o seu servidor
- Servidor próprio com PHP-FPM: use o arquivo do pool (por exemplo
/etc/php/8.3/fpm/pool.d/casapinho.conf, o caminho varia por distribuição e versão) comphp_admin_flagephp_admin_value. Valores definidos assim não podem ser alterados porini_setdentro da aplicação, o que protege contra a linha esquecida. - Apache com mod_php: use
php_admin_flagno VirtualHost, com o mesmo efeito. - Hospedagem compartilhada: muitos painéis têm um editor de configuração do PHP. Se o PHP roda como CGI ou FastCGI, um arquivo
.user.inina raiz do site também funciona, mas as mudanças podem levar alguns minutos para valer por causa do cache desse arquivo. - Recarregue o serviço depois de alterar o pool ou o
php.ini:sudo systemctl reload php8.3-fpm, ajustando o nome da unidade para a sua versão.
; /etc/php/8.3/fpm/pool.d/casapinho.conf
php_admin_flag[display_errors] = off
php_admin_flag[log_errors] = on
php_admin_value[error_log] = /var/log/php/casapinho-error.log
Crie a pasta do log com dono igual ao usuário do pool e configure rotação com logrotate. Log que cresce sem limite enche o disco, e disco cheio derruba o site de um jeito muito pior que um warning na tela.
Um código de ocorrência que liga a tela ao log
Com a exibição desligada, um erro fatal vira uma página em branco ou um 500 cru. Melhor do que isso é uma página com uma frase e um código curto, que a pessoa pode mandar ao suporte. Você procura o código no log e acha a pilha completa.
<?php
// bootstrap.php, incluído no início de toda requisição
declare(strict_types=1);
set_error_handler(function (int $nivel, string $mensagem, string $arquivo, int $linha): bool {
if (!(error_reporting() & $nivel)) {
return false;
}
if ($nivel & (E_NOTICE | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED)) {
return false;
}
throw new ErrorException($mensagem, 0, $nivel, $arquivo, $linha);
});
function paginaDeErro(string $codigo): void
{
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
}
echo '<p>Algo deu errado do nosso lado. Se precisar de ajuda, informe o código '
. htmlspecialchars($codigo, ENT_QUOTES, 'UTF-8') . '.</p>';
}
set_exception_handler(function (Throwable $e): void {
$codigo = strtoupper(bin2hex(random_bytes(4)));
error_log('[' . $codigo . '] ' . get_class($e) . ': ' . $e->getMessage()
. ' em ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL . $e->getTraceAsString());
paginaDeErro($codigo);
});
register_shutdown_function(function (): void {
$erro = error_get_last();
if ($erro !== null && in_array($erro['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
$codigo = strtoupper(bin2hex(random_bytes(4)));
error_log('[' . $codigo . '] fatal: ' . $erro['message'] . ' em ' . $erro['file'] . ':' . $erro['line']);
paginaDeErro($codigo);
}
});
O set_error_handler transforma warnings em exceções, para que um aviso não passe adiante com a página meio montada. Notices e deprecations voltam para o tratamento padrão do PHP, que só os registra no log: assim uma atualização de versão que deprecia uma função não derruba páginas que ainda funcionam. O register_shutdown_function pega os fatais, que os outros dois handlers não alcançam. Antes de registrar a mensagem, confira se ela não carrega senha ou dado pessoal; o guia de log sem senha mostra o que filtrar.
Se você usa framework, ele já faz esse papel e tem uma chave própria para o modo de depuração: APP_DEBUG=false no Laravel, WP_DEBUG_DISPLAY como falso no WordPress e, no Drupal, a opção de não exibir mensagens de erro na página de log e erros (admin/config/development/logging). Essa chave não substitui o display_errors do servidor, porque o framework só entra depois que o PHP já começou a rodar.
Como provar que a configuração pegou
Rodar php -i no terminal mostra a configuração do PHP de linha de comando, que costuma usar outro php.ini. O que vale é a configuração vista pelo servidor web. Crie temporariamente um arquivo com nome difícil de adivinhar, peça pelo navegador e apague logo em seguida:
<?php
header('Content-Type: text/plain; charset=utf-8');
foreach (['display_errors', 'display_startup_errors', 'log_errors', 'error_reporting', 'error_log', 'expose_php'] as $chave) {
echo $chave . ' = ' . var_export(ini_get($chave), true) . PHP_EOL;
}
Depois force um erro de propósito num ambiente de homologação com a mesma configuração e confira três coisas: a página mostra só a frase e o código, o log tem a linha com o mesmo código e a resposta não traz X-Powered-By.
Erros comuns
- Configurar só com
ini_setno código: erros de sintaxe e avisos anteriores à linha continuam aparecendo na tela. - Baixar o
error_reportingpara esconder avisos: o log fica mudo e você descobre o problema quando ele já derrubou o site. - Conferir com
php -ino terminal: você vê a configuração da linha de comando, não a do servidor web, e conclui errado que está tudo desligado. - Deixar o arquivo de diagnóstico no ar: um
phpinfo()esquecido expõe caminhos, módulos e variáveis de ambiente. - Log sem rotação: o disco enche e o site cai por um motivo que não tem nada a ver com o erro original.
- Copiar a pilha do erro em ticket ou grupo aberto: o mapa que você escondeu da página reaparece em outro lugar.
Checklist
display_errorsedisplay_startup_errorsestão desligados na configuração do servidor, não só no código.log_errorsestá ligado eerror_reportingéE_ALL.- O arquivo de log existe, recebe gravações e tem rotação configurada.
- A página de erro mostra uma frase e um código que aparece também no log.
- A resposta HTTP não traz o cabeçalho
X-Powered-By. - O modo de depuração do framework está desligado em produção.
- Nenhum arquivo de diagnóstico ficou publicado.
Provoque agora um erro numa página de homologação configurada igual à produção: o que aparece na tela é um código para o suporte ou o caminho completo do seu servidor?
Referência: http://drupal-br.org/node/6870