← Voltar ao blog

Log de erros em PHP: o que configurar em produção

Erro de PHP em produção tem que ir para o log, nunca para a tela: display_errors desligado, log_errors ligado e arquivo de log fora da pasta pública.


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

  1. Confira as diretivas atuais com o script de diagnóstico.
  2. Desligue display_errors e ligue log_errors no php.ini.
  3. Garanta que o arquivo de log está fora da pasta pública e com permissão restrita.
  4. Registre os handlers no bootstrap, antes do código de negócio.
  5. Adicione o request_id e devolva o código na resposta de erro.
  6. Configure rotação e retenção.
  7. Revise o que está sendo gravado e mascare dado pessoal.
  8. Teste provocando um erro de propósito, em homologação.

Checklist

  • display_errors desligado em produção.
  • log_errors ligado com caminho fora do public_html.
  • error_reporting cobrindo o que importa.
  • Handler de exceção e de erro registrado no bootstrap.
  • register_shutdown_function cobrindo erro fatal.
  • request_id no 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: