Tratamento de Erros e Exceções

Tratamento de Erros e Exceções

O PHP tem dois sistemas para lidar com o que dá errado: os níveis de erro herdados das primeiras versões e as exceções, que são o caminho moderno. Como os dois convivem, o que muda no PHP 8, e por que a hierarquia Throwable se divide em dois ramos que não se capturam juntos.
PHP

17 min de leitura

Todo programa encontra situações inesperadas: um arquivo que não existe, uma conexão de banco que falha, um valor inválido recebido do usuário. A questão não é se esses problemas vão acontecer — é quando. A diferença entre um software robusto e um frágil é precisamente como ele lida com essas situações.

O PHP oferece dois mecanismos principais para isso: o sistema tradicional de erros — com níveis como E_WARNING e E_NOTICE — e o sistema moderno de exceções com try, catch e finally. Entender ambos, e saber quando usar cada um, é essencial para qualquer desenvolvedor PHP sério.

O sistema de erros do PHP

Antes das exceções, o PHP lidava com problemas através de um sistema de níveis de erro. Esses ainda existem e aparecem com frequência:

<?php
declare(strict_types=1);

// Níveis de erro mais comuns:
// E_ERROR   — erro fatal, encerra o script
// E_WARNING — aviso, script continua
// E_NOTICE  — informativo, script continua
// E_DEPRECATED — uso de recurso obsoleto

// Configurando exibição de erros (apenas em desenvolvimento!)
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL); // reporta todos os níveis

// Em produção, NUNCA exiba erros — apenas registre no log
// ini_set('display_errors', '0');
// ini_set('log_errors', '1');
// ini_set('error_log', '/var/log/php_errors.log');

// error_log() — registra uma mensagem no log manualmente
error_log("Algo inesperado aconteceu na função processar()");

// trigger_error() — dispara um erro customizado
trigger_error("Valor fora do intervalo esperado", E_USER_WARNING);

A partir do PHP 8, muitos comportamentos que antes geravam E_WARNING ou E_NOTICE silenciosamente agora lançam TypeError ou ValueError — um avanço importante para detectar problemas mais cedo.

Exceções: try, catch e finally

O sistema de exceções é a abordagem moderna para tratar erros em PHP. Uma exceção é um objeto que representa uma situação excepcional — algo que interrompeu o fluxo normal do programa:

<?php
declare(strict_types=1);

// Estrutura básica
try {
    // Código que pode lançar uma exceção
    // Se uma exceção for lançada, o PHP sai imediatamente deste bloco
    // e vai para o catch correspondente

    $resultado = dividir(10, 0);
    echo $resultado; // esta linha NÃO executa se dividir() lançar exceção

} catch (InvalidArgumentException $e) {
    // Captura apenas InvalidArgumentException (e subclasses)
    echo "Argumento inválido: " . $e->getMessage() . "\n";

} catch (RuntimeException $e) {
    // Captura RuntimeException se o catch anterior não pegou
    echo "Erro em tempo de execução: " . $e->getMessage() . "\n";

} catch (Exception $e) {
    // Captura qualquer Exception não capturada acima
    echo "Erro inesperado: " . $e->getMessage() . "\n";

} finally {
    // SEMPRE executa — com ou sem exceção, com ou sem return
    // Ideal para liberar recursos: fechar conexões, arquivos, etc.
    echo "Bloco finally executado.\n";
}

function dividir(int $a, int $b): float
{
    if ($b === 0) {
        // throw lança a exceção — interrompe a função imediatamente
        throw new InvalidArgumentException("Divisão por zero não é permitida.");
    }

    return $a / $b;
}

A hierarquia de exceções do PHP

O PHP tem uma hierarquia bem definida de classes de erro e exceção. Entender essa hierarquia é fundamental para capturar as exceções certas:

Throwable (interface)
├── Error (erros internos do PHP)
│   ├── TypeError
│   ├── ValueError
│   ├── ArithmeticError
│   │   └── DivisionByZeroError
│   ├── ParseError
│   └── OutOfMemoryError (PHP 8.2)
│
└── Exception (exceções de aplicação)
    ├── LogicException
    │   ├── BadFunctionCallException
    │   │   └── BadMethodCallException
    │   ├── DomainException
    │   ├── InvalidArgumentException
    │   ├── LengthException
    │   └── OutOfRangeException
    │
    └── RuntimeException
        ├── OutOfBoundsException
        ├── OverflowException
        ├── RangeException
        ├── UnderflowException
        └── UnexpectedValueException
<?php
declare(strict_types=1);

// TypeError — lançado automaticamente com strict_types
function somar(int $a, int $b): int
{
    return $a + $b;
}

try {
    somar(1, "dois"); // TypeError automático com strict_types=1
} catch (TypeError $e) {
    echo "Tipo errado: " . $e->getMessage() . "\n";
}

// Error vs Exception — ambos implementam Throwable
// Você pode capturar ambos com Throwable
try {
    // código que pode lançar Error ou Exception
    operacaoArriscada();
} catch (Throwable $e) {
    // captura qualquer Error ou Exception
    echo get_class($e) . ": " . $e->getMessage() . "\n";
}

// Capturar múltiplos tipos no mesmo catch — PHP 8
try {
    processar($entrada);
} catch (InvalidArgumentException | ValueError $e) {
    echo "Valor inválido: " . $e->getMessage() . "\n";
}

Criando exceções customizadas

Em projetos reais, você cria suas próprias exceções para representar erros específicos do domínio da aplicação. Isso torna o código mais expressivo e o tratamento de erros mais preciso:

<?php
declare(strict_types=1);

// Exceção base do domínio — todas as exceções da aplicação herdam desta
class AppException extends RuntimeException {}

// Exceções específicas do domínio
class UsuarioNaoEncontradoException extends AppException
{
    public function __construct(int $id)
    {
        // Chama o construtor pai com uma mensagem descritiva
        parent::__construct("Usuário com ID $id não encontrado.");
    }
}

class SaldoInsuficienteException extends AppException
{
    public function __construct(
        private readonly float $saldoAtual,
        private readonly float $valorSolicitado,
    ) {
        parent::__construct(
            "Saldo insuficiente. Disponível: R$ {$saldoAtual}. Solicitado: R$ {$valorSolicitado}."
        );
    }

    public function getSaldoAtual(): float { return $this->saldoAtual; }
    public function getValorSolicitado(): float { return $this->valorSolicitado; }
}

// Usando as exceções customizadas
function buscarUsuario(int $id): array
{
    $usuarios = [1 => ["nome" => "Ana"], 2 => ["nome" => "Bruno"]];

    if (!isset($usuarios[$id])) {
        throw new UsuarioNaoEncontradoException($id);
    }

    return $usuarios[$id];
}

function realizarSaque(float $saldo, float $valor): float
{
    if ($valor > $saldo) {
        throw new SaldoInsuficienteException($saldo, $valor);
    }

    return $saldo - $valor;
}

// Tratamento preciso por tipo de exceção
try {
    $usuario = buscarUsuario(99);
} catch (UsuarioNaoEncontradoException $e) {
    echo $e->getMessage(); // "Usuário com ID 99 não encontrado."
}

try {
    $novoSaldo = realizarSaque(100.0, 250.0);
} catch (SaldoInsuficienteException $e) {
    echo $e->getMessage();
    echo "Faltam R$ " . ($e->getValorSolicitado() - $e->getSaldoAtual()) . "\n";
}

finally — garantindo limpeza de recursos

O bloco finally é executado sempre — seja o código bem-sucedido, seja uma exceção lançada, seja um return executado dentro do try. É o lugar ideal para liberar recursos:

<?php
declare(strict_types=1);

function processarArquivo(string $caminho): string
{
    $arquivo = null;

    try {
        $arquivo = fopen($caminho, 'r');

        if ($arquivo === false) {
            throw new RuntimeException("Não foi possível abrir: $caminho");
        }

        $conteudo = fread($arquivo, filesize($caminho));

        if ($conteudo === false) {
            throw new RuntimeException("Erro ao ler o arquivo: $caminho");
        }

        return $conteudo; // mesmo com return aqui...

    } catch (RuntimeException $e) {
        error_log($e->getMessage());
        return ""; // ...ou com return no catch...

    } finally {
        // ...finally SEMPRE executa
        // Garante que o arquivo será fechado em qualquer situação
        if ($arquivo !== null && is_resource($arquivo)) {
            fclose($arquivo);
        }
        echo "Arquivo processado (recurso liberado).\n";
    }
}

Re-lançando exceções

Às vezes você quer capturar uma exceção, fazer algo com ela (como registrar no log), e então relançar para que o chamador possa tratá-la também. Ou quer envolver uma exceção de baixo nível em uma de mais alto nível:

<?php
declare(strict_types=1);

class DatabaseException extends RuntimeException {}

function buscarDoBanco(int $id): array
{
    try {
        // Simula uma operação de banco que pode falhar
        if ($id <= 0) {
            throw new InvalidArgumentException("ID deve ser positivo.");
        }

        // Simula falha de conexão
        // $pdo->query("SELECT * FROM usuarios WHERE id = $id");

    } catch (InvalidArgumentException $e) {
        // Re-lança sem envolver — sobe para o chamador
        throw $e;

    } catch (\PDOException $e) {
        // Envolve exceção de baixo nível em exceção de domínio
        // O segundo argumento é a "exceção anterior" — preserva o stack trace original
        throw new DatabaseException(
            "Falha ao buscar usuário $id no banco.",
            previous: $e  // PHP 8: named argument
        );
    }

    return [];
}

// Acessando a cadeia de exceções
try {
    buscarDoBanco(-1);
} catch (DatabaseException $e) {
    echo $e->getMessage() . "\n";

    // $e->getPrevious() retorna a exceção original
    if ($anterior = $e->getPrevious()) {
        echo "Causa: " . $anterior->getMessage() . "\n";
    }
}

Boas práticas no tratamento de erros

Capture exceções específicas, não genéricas. Capturar Exception em todo lugar esconde problemas. Capture o tipo mais específico possível e deixe as inesperadas borbulhar.

Não use exceções para controle de fluxo normal. Exceções são para situações excepcionais. Verificar se um usuário existe antes de buscá-lo é melhor do que lançar uma exceção e capturá-la em cada chamada.

Sempre registre exceções não tratadas. Qualquer exceção que chegue ao topo da pilha de chamadas deve ser registrada em log — nunca engolida silenciosamente.

Use finally para liberar recursos. Sempre que abrir um arquivo, conexão ou lock, use finally para garantir que será fechado.

<?php
declare(strict_types=1);

// ✗ Exceção para controle de fluxo — evite
function processarUsuario(int $id): void
{
    try {
        $usuario = buscarUsuario($id);
        // processa...
    } catch (UsuarioNaoEncontradoException $e) {
        // simplesmente ignora — usuário não existe
    }
}

// ✓ Verificação explícita — mais claro
function processarUsuario(int $id): void
{
    if (!usuarioExiste($id)) {
        return; // simplesmente não processa
    }

    $usuario = buscarUsuario($id);
    // processa...
}

// ✗ Engolir exceção silenciosamente — muito perigoso
try {
    operacaoCritica();
} catch (Exception $e) {
    // nada aqui — você nunca vai saber que isso falhou!
}

// ✓ Sempre registre
try {
    operacaoCritica();
} catch (Exception $e) {
    error_log("[ERRO] " . $e->getMessage() . " em " . $e->getFile() . ":" . $e->getLine());
    throw $e; // re-lança para o chamador tratar também
}

Tratar erro não é capturar tudo: é decidir, caso a caso, quem tem condição de reagir. Exceção capturada por quem não sabe o que fazer com ela vira defeito escondido, e o catch vazio — presente em todo sistema antigo — é a forma mais cara de silenciar um problema, porque adia o sintoma até o ponto em que ninguém mais liga a causa. Em desenvolvimento, deixe tudo aparecer; em produção, nada na tela e tudo no log. Essas duas linhas de configuração resolvem mais depuração futura do que qualquer try a mais.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O bloco abaixo deveria capturar qualquer problema e registrar no log. Uma chamada com tipo errado derruba a aplicação mesmo assim. Por quê?

<?php
try {
    $total = calcularTotal($itens);   // TypeError: argumento deve ser array
} catch (Exception $e) {
    error_log($e->getMessage());
}
Ver resposta

✓ Resposta: Porque TypeError não é uma Exception. Desde o PHP 7 a raiz da hierarquia é a interface Throwable, e dela descem dois ramos irmãos: Exception, para situações previstas pela aplicação, e Error, para falhas do motor da linguagem — TypeError, ValueError, ArgumentCountError, DivisionByZeroError, ParseError. Capturar Exception não alcança o segundo ramo, e é por isso que o erro escapa. A correção depende da intenção. Num catch de último recurso, que só registra e devolve uma página de erro decente, o certo é catch (Throwable $e) — pega os dois ramos. Mas no meio do código, capturar Error quase nunca é o que se quer: um TypeError indica defeito de programação, não condição de execução, e escondê-lo com um catch transforma um erro que apareceria no desenvolvimento em comportamento errado silencioso na produção. A divisão saudável é esta: trate Exception onde você sabe reagir, deixe Error subir até o handler global, e faça esse handler registrar tudo com stack trace. E vale lembrar que a hierarquia mudou de propósito no PHP 7 justamente para permitir essa separação — em PHP 5, erro fatal não era capturável de forma alguma.

Exercício 2

O que esta função devolve, e por que isso é considerado uma armadilha?

<?php
function salvar(array $dados): string {
    try {
        gravarNoBanco($dados);
        return "gravado";
    } catch (PDOException $e) {
        return "falhou";
    } finally {
        return "finalizado";
    }
}
Ver resposta

✓ Resposta: Devolve sempre "finalizado", em qualquer cenário. O finally executa depois do try e do catch, e um return dentro dele descarta o valor que já estava a caminho — inclusive o do catch. Pior: se gravarNoBanco lançar uma exceção que não seja PDOException, o return do finally engole a exceção inteira, e a função devolve "finalizado" como se tudo tivesse dado certo. É a forma mais eficiente de esconder um defeito: quem chama recebe sucesso, o log não registra nada, e o dado não foi gravado. A regra que evita a classe inteira de problema: o finally serve para liberar recurso, não para produzir resultado — fechar arquivo, devolver conexão ao pool, remover arquivo temporário, desfazer transação. Nunca coloque return, break ou continue dentro dele. Repare também que a função tem um problema anterior a esse: capturar PDOException e devolver a string "falhou" obriga quem chama a comparar textos para saber o que aconteceu, e perde a causa original. Devolver bool, ou deixar a exceção subir com contexto, é mais honesto que um código de erro em forma de frase.

Exercício 3

Ordene estes catch corretamente e explique o que acontece com a ordem como está.

<?php
try {
    processar($pedido);
} catch (Exception $e) {
    registrar($e);
} catch (InvalidArgumentException $e) {
    avisarUsuario($e->getMessage());
}
Ver resposta

✓ Resposta: Como está, o segundo catch nunca executa. O PHP testa os blocos de cima para baixo e usa o primeiro cujo tipo corresponda — como InvalidArgumentException descende de LogicException, que descende de Exception, ela é capturada pelo primeiro bloco, e avisarUsuario se torna código morto. A ordem correta vai do mais específico para o mais genérico: primeiro InvalidArgumentException, depois Exception. O detalhe que torna isso perigoso é que o PHP não avisa — não há erro nem warning para catch inalcançável, ao contrário do que acontece em linguagens compiladas. O defeito só aparece como "a mensagem para o usuário nunca chega", e costuma ser procurado em qualquer outro lugar. Dois recursos ajudam na prática: desde o PHP 7.1 um único bloco captura vários tipos irmãos, catch (InvalidArgumentException | OutOfRangeException $e), o que evita duplicação; e desde o 8.0 a variável é opcional, catch (PDOException), útil quando você só quer mudar o fluxo e não vai olhar a exceção. Vale conhecer também a raiz do desenho da biblioteca padrão: LogicException é para erro de programação, que deveria ser corrigido no código, e RuntimeException é para o que só se descobre em execução — a distinção orienta qual você deve capturar e qual deve deixar estourar.

Exercício 4

Em PHP 8, o que cada linha produz? E o que muda ao prefixar com @?

<?php
echo 10 % 0;
echo 10 / 0;
echo intdiv(10, 0);
Ver resposta

✓ Resposta: As três lançam DivisionByZeroError — a primeira com a mensagem "Modulo by zero" e as outras com "Division by zero". É mais uma mudança do PHP 8: até o 7.x, 10 / 0 emitia um Warning e devolvia INF, um valor que seguia adiante contaminando cálculos; hoje interrompe na hora. E como DivisionByZeroError descende de Error, e não de Exception, ela escapa de um catch (Exception) — o mesmo caso do primeiro exercício. Quanto ao @: ele não funciona aqui. O operador de supressão silencia mensagens de erro do sistema antigo (Warning, Notice, Deprecated), mas não impede exceção nem Error — o script morre igual, apenas sem a mensagem que explicaria o motivo. Aliás, é essa a razão para evitar o @ em geral: ele esconde o sintoma e mantém o problema, e ainda tem custo de desempenho porque o PHP precisa montar e descartar a mensagem. Onde o código antigo usava @fopen(...), o equivalente moderno é verificar antes (is_readable) ou tratar o false de retorno; para dividir, o certo é checar o divisor, if ($d === 0), em vez de deixar a linguagem decidir por você.

Exercício 5

Desafio: uma camada de repositório grava um pedido. O banco pode falhar por indisponibilidade, por violação de chave única ou por dado inválido. Descreva o que capturar, o que deixar subir e como o chamador fica sabendo o que houve.

Ver resposta

✓ Resposta: O princípio é que o repositório deve falar a linguagem do domínio, não a do banco: quem chama não deveria precisar conhecer PDOException nem código SQLSTATE para decidir o que fazer.

<?php

declare(strict_types=1);

final class PedidoJaExiste extends RuntimeException {}
final class PersistenciaIndisponivel extends RuntimeException {}

final class PedidoRepository
{
    public function __construct(private readonly PDO $pdo) {}

    public function salvar(Pedido $pedido): void
    {
        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO pedidos (codigo, valor_centavos) VALUES (?, ?)'
            );
            $stmt->execute([$pedido->codigo, $pedido->valorCentavos]);
        } catch (PDOException $e) {
            // 23000 é violação de restrição de integridade — chave única, aqui.
            // O código vem do padrão SQLSTATE e é estável entre drivers.
            if ($e->getCode() === '23000') {
                throw new PedidoJaExiste(
                    "Pedido {$pedido->codigo} já registrado", 0, $e
                );
            }

            // Qualquer outra falha do banco é indisponibilidade do ponto de
            // vista de quem chama — e a causa original vai junto, no previous.
            throw new PersistenciaIndisponivel(
                'Falha ao gravar o pedido', 0, $e
            );
        }
    }
}

Três decisões sustentam o desenho. A primeira é traduzir, não vazar: PDOException morre no repositório, e sobe uma exceção do domínio. Isso permite trocar PDO por outra coisa sem tocar em quem chama — e evita que a camada de aplicação precise saber o que é SQLSTATE. A segunda é o terceiro argumento, $e, que vira o previous: a causa original fica encadeada e aparece inteira no log, com a consulta e a linha que falharam, enquanto a mensagem que sobe permanece limpa. Perder o previous é o erro mais comum ao traduzir exceção, e transforma depuração em adivinhação. A terceira é o que não está lá: dado inválido — valor negativo, código vazio — não deveria chegar ao repositório. Essa validação pertence ao construtor de Pedido, que lança InvalidArgumentException antes de existir objeto inválido no sistema. Quanto ao chamador, ele reage pelo tipo: PedidoJaExiste vira uma mensagem para o usuário e uma resposta 409; PersistenciaIndisponivel vira nova tentativa, ou 503, e um alerta para a equipe — decisões diferentes, que só são possíveis porque os dois casos têm tipos diferentes.

Comentários

Mais em PHP

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…

Variáveis, Tipos de Dados e Operadores
Variáveis, Tipos de Dados e Operadores

Toda variável em PHP guarda um valor de algum tipo, e a linguagem converte…

Orientação a Objetos: Fundamentos
Orientação a Objetos: Fundamentos

Uma classe define o molde; cada objeto guarda o próprio estado. Construtor…