Em produção, erro de PHP deve ir para o log e nunca para a tela. Isso significa display_errors=Off, log_errors=On e um caminho de log fora da pasta pública, com handler que capture exceção, erro fatal e dê ao usuário uma resposta genérica com um identificador rastreável.
Configurar isso leva vinte minutos e resolve a maior parte dos "o cliente disse que deu erro e não sei onde". O resto do artigo mostra a configuração, os handlers que faltam na maioria dos sistemas e o que nunca pode entrar no log.
O que precisa estar ligado em produção?
Três diretivas: erro exibido desligado, log ligado e nível completo registrado.
; php.ini — produção
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/erros.log
error_reporting = E_ALL & ~E_DEPRECATED
log_errors_max_len = 0
E_ALL com exceção de E_DEPRECATED evita que uma biblioteca antiga encha o disco com aviso inútil. log_errors_max_len = 0 remove o limite de tamanho da mensagem — o padrão corta stack trace no meio.
Confira o que está valendo em tempo de execução, porque ini_set no código pode sobrescrever o php.ini:
<?php
// tela de diagnóstico — remover depois de conferir
header('Content-Type: text/plain; charset=UTF-8');
echo 'display_errors: ', ini_get('display_errors'), PHP_EOL;
echo 'log_errors: ', ini_get('log_errors'), PHP_EOL;
echo 'error_log: ', ini_get('error_log'), PHP_EOL;
echo 'error_reporting:', error_reporting(), PHP_EOL;
Por que display_errors ligado é um problema de segurança?
Porque mensagem de erro revela caminho absoluto, nome de tabela, consulta SQL e versão do banco. É mapa gratuito para quem procura falha de injeção ou caminho de arquivo.
Um erro de conexão exibido na tela entrega usuário, host e nome do banco:
Warning: ibase_connect(): unable to connect to host "10.0.0.12",
user "app_sistema", database "/dados/sistema.fdb"
O mesmo erro no log, sem tela, é diagnóstico. Na tela, é vazamento. Em endpoint público, é convite.
Como capturar o erro que o handler padrão não pega?
set_error_handler cuida de Warning, Notice e Deprecated. Exceção não tratada vai para set_exception_handler. Erro fatal (E_ERROR, parse error, estouro de memória) não passa por nenhum dos dois — só por register_shutdown_function.
| Tipo de falha | Quem captura | Exemplo |
|---|---|---|
Notice, Warning, Deprecated |
set_error_handler |
índice indefinido, include ausente |
| Exceção não tratada | set_exception_handler |
throw sem try/catch |
| Erro fatal, parse error, memória | register_shutdown_function |
chamada a função inexistente, memory_limit |
| Erro antes do bootstrap | log do PHP (error_log) |
parse error no arquivo do bootstrap |
<?php
// bootstrap.php — registrar cedo, antes de qualquer include de negócio
set_error_handler(function (int $no, string $str, string $file = '', int $line = 0): bool {
// respeita o error_reporting atual
if (!(error_reporting() & $no)) {
return false;
}
error_log(sprintf('[%s] %s em %s:%d', 'PHP', $str, $file, $line));
return true;
});
set_exception_handler(function (Throwable $e): void {
error_log(sprintf('[%s] %s em %s:%d', get_class($e), $e->getMessage(), $e->getFile(), $e->getLine()));
http_response_code(500);
echo 'Erro interno. Informe o código ao suporte.';
});
O shutdown handler fecha o buraco do erro fatal:
<?php
register_shutdown_function(function (): void {
$err = error_get_last();
if ($err === null || !in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) {
return;
}
error_log(sprintf('[FATAL] %s em %s:%d', $err['message'], $err['file'], $err['line']));
if (!headers_sent()) {
http_response_code(500);
echo 'Erro interno.';
}
});
Sem esse trecho, o "erro 500 sem nada no log" continua acontecendo justamente nos casos mais graves.
Como ligar o erro ao usuário que reclamou?
Gere um identificador por requisição e devolva só ele na tela. Depois, busque esse código no log.
<?php
$requestId = bin2hex(random_bytes(8));
define('REQUEST_ID', $requestId);
set_exception_handler(function (Throwable $e) use ($requestId): void {
error_log(sprintf('[%s] %s em %s:%d', $requestId, $e->getMessage(), $e->getFile(), $e->getLine()));
http_response_code(500);
echo "Erro interno. Código: {$requestId}";
});
Com o código que o cliente informou, a busca no log é direta:
Select-String -Path C:\logs\php\erros.log -Pattern "a1b2c3d4e5f60718" -Context 0,3
O que nunca pode ser gravado no log?
Dado pessoal e segredo. Log não é lugar para CPF completo, senha, token de sessão, cartão ou corpo inteiro de requisição.
<?php
// errado: grava credencial e dado pessoal
error_log('Login falhou: ' . json_encode($_POST));
// certo: mascara o que identifica e omite o que autentica
$cpf = $_POST['cpf'] ?? '';
$mask = substr($cpf, 0, 3) . '******' . substr($cpf, -2);
error_log(sprintf('Login falhou cpf=%s ip=%s', $mask, $_SERVER['REMOTE_ADDR'] ?? '-'));
A mesma regra vale para SQL: registre o comando e o erro, não o array de parâmetros com dados do cliente. Em sistema com LGPD, log com dado pessoal precisa de base legal, prazo de retenção e controle de acesso — na prática, é mais simples não gravar.
Como evitar que o log encha o disco?
Rotação por tamanho, com retenção definida. Um log de 20 GB derruba o servidor de um jeito mais difícil de diagnosticar do que o bug original.
# /etc/logrotate.d/php
/var/log/php/*.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
create 0640 www-data www-data
}
Para sistema com volume alto, prefira log estruturado em JSON, uma linha por evento, e envie para um coletor. Fica fácil filtrar por request_id, usuario ou rota sem grep artesanal.
<?php
function log_event(string $nivel, string $msg, array $ctx = []): void
{
$linha = [
'ts' => date('c'),
'nivel' => $nivel,
'msg' => $msg,
'rid' => defined('REQUEST_ID') ? REQUEST_ID : null,
'ctx' => $ctx,
];
error_log(json_encode($linha, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
Passo a passo para arrumar um sistema sem log
- Confira as diretivas atuais com o script de diagnóstico.
- Desligue
display_errorse liguelog_errorsnophp.ini. - Garanta que o arquivo de log está fora da pasta pública e com permissão restrita.
- Registre os handlers no bootstrap, antes do código de negócio.
- Adicione o
request_ide devolva o código na resposta de erro. - Configure rotação e retenção.
- Revise o que está sendo gravado e mascare dado pessoal.
- Teste provocando um erro de propósito, em homologação.
Checklist
-
display_errorsdesligado em produção. -
log_errorsligado com caminho fora dopublic_html. -
error_reportingcobrindo o que importa. - Handler de exceção e de erro registrado no bootstrap.
-
register_shutdown_functioncobrindo erro fatal. -
request_idno log e na resposta ao usuário. - Dado pessoal e credencial fora do log.
- Rotação com retenção configurada.
- Log de teste gerado e localizado.
Perguntas frequentes
Preciso de display_errors ligado para depurar?
Só em ambiente local ou homologação, nunca em produção. Em produção, o erro vai para o log e o usuário recebe mensagem genérica com um código de rastreio.
register_shutdown_function substitui o try/catch?
Não. Ele cobre o que escapa do try/catch: erro fatal, parse error e falta de memória. Os três níveis são complementares.
Posso gravar a query SQL completa no log para investigar?
O comando pode ser gravado; os parâmetros com dado pessoal, não. Se precisar do valor, mascare ou registre apenas o identificador da linha.
Conclusão
Log de erro em PHP é configuração, não biblioteca: display_errors desligado, log_errors ligado, handlers cobrindo exceção e erro fatal, request_id para o suporte rastrear e rotação para o disco não encher. Vinte minutos de ajuste trocam "deu erro" por "esse erro, nessa linha, desse usuário".
Se o erro vier de SQL, o Sqlify ajuda a reescrever a consulta a partir da descrição em português, reduzindo a chance de erro de sintaxe e de injeção no caminho.
Leia também: