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

SOLID na Prática
SOLID na Prática

Cada um dos cinco princípios apresentado em par: o código que viola e o que…

Segurança em Formulários: XSS, CSRF e SQL Injection
Segurança em Formulários: XSS, CSRF e SQL Injection

Como cada um dos três ataques funciona por dentro e por que as defesas acabam…

Exceções Avançadas
Exceções Avançadas

Uma hierarquia de exceções bem desenhada diz ao chamador o que ele pode tratar…