PHP

Exceções Avançadas Já leu

12 min de leitura

Exceções Avançadas
Tratamento de erros é onde código bom se separa de código profissional. O básico — try/catch/finally — você j&aacu

Tratamento de erros é onde código bom se separa de código profissional. O básico — try/catch/finally — você já domina. Neste artigo avançamos para o design de hierarquias de exceções, o contrato Throwable, encadeamento de causa com $previous, handlers globais com set_exception_handler(), e os antipadrões que transformam exceções de aliadas em pesadelo de manutenção.

Esses conceitos aparecem diretamente em frameworks PHP: o Laravel usa uma hierarquia própria (HttpException, ModelNotFoundException, ValidationException), o Symfony tem HttpExceptionInterface que o kernel reconhece automaticamente, e o tratamento correto de exceções determina se sua API retorna uma mensagem útil ou expõe um stack trace inteiro para o cliente.

A hierarquia Throwable do PHP

No PHP 7+, toda classe lançável implementa a interface Throwable. Ela tem dois ramos: Error (erros internos do PHP) e Exception (exceções de aplicação). A distinção é importante: Error geralmente indica um bug no código, enquanto Exception representa condições previstas e recuperáveis.

<?php
declare(strict_types=1);

// Capturar Exception NÃO captura Error — TypeError é um Error
function somaEstrita(int $a, int $b): int { return $a + $b; }

try {
    somaEstrita("dois", 3);   // TypeError — string onde int é esperado
} catch (Exception $e) {
    // NÃO captura — TypeError é Error, não Exception
} catch (TypeError $e) {
    // Captura corretamente
    echo "TypeError: " . $e->getMessage() . "\n";
}

// Throwable captura TUDO — use apenas em handlers de último recurso
try {
    somaEstrita("dois", 3);
} catch (\Throwable $t) {
    // Captura Error E Exception — útil apenas nas bordas do sistema
    echo get_class($t) . ": " . $t->getMessage() . "\n";
    // TypeError: somaEstrita(): Argument #1 must be of type int, string given
}

// Múltiplos tipos num mesmo catch — PHP 8+
try {
    // código que pode lançar tipos diferentes
} catch (InvalidArgumentException | OverflowException $e) {
    // trata os dois da mesma forma — sem duplicar código
    echo "Erro de validação: " . $e->getMessage();
}

Desenhando sua hierarquia de exceções

Uma hierarquia bem projetada permite que o código cliente capture exceções no nível de granularidade certo. A regra de ouro: crie uma exceção base por módulo/domínio e derive exceções específicas dela. Assim o chamador pode capturar PedidoException para tratar qualquer erro de pedido, ou PedidoNaoEncontradoException para um caso específico.

<?php
declare(strict_types=1);

namespace MeuApp\Exceptions;

// Raiz da hierarquia — toda exceção da aplicação estende esta
abstract class AppException extends \RuntimeException {}

// ── HTTP / API ───────────────────────────────────────────────────────
class HttpException extends AppException
{
    public function __construct(
        public readonly int $statusHttp,
        string $mensagem,
        \Throwable $anterior = null,
    ) {
        parent::__construct($mensagem, $statusHttp, $anterior);
    }
}

class NaoEncontradoException extends HttpException
{
    public function __construct(string $recurso, int|string $id)
    {
        parent::__construct(404, "{$recurso} com ID '{$id}' não encontrado.");
    }
}

class NaoAutorizadoException extends HttpException
{
    public function __construct(string $acao = "acessar este recurso")
    {
        parent::__construct(403, "Você não tem permissão para {$acao}.");
    }
}

// ── VALIDAÇÃO ────────────────────────────────────────────────────────
class ValidacaoException extends AppException
{
    public function __construct(
        /** @var array<string, string[]> */
        public readonly array $erros,
    ) {
        parent::__construct("Erro de validação: " . implode("; ", array_merge(...$erros)));
    }

    public function errosPorCampo(string $campo): array
    {
        return $this->erros[$campo] ?? [];
    }
}

// ── DOMÍNIO: PEDIDOS ─────────────────────────────────────────────────
abstract class PedidoException extends AppException {}

class PedidoNaoEncontradoException extends PedidoException
{
    public function __construct(
        public readonly int $pedidoId,
        \Throwable $anterior = null,
    ) {
        parent::__construct("Pedido #{$pedidoId} não encontrado.", 404, $anterior);
    }
}

class EstoqueInsuficienteException extends PedidoException
{
    public function __construct(
        public readonly string $produto,
        public readonly int    $solicitado,
        public readonly int    $disponivel,
    ) {
        parent::__construct(
            "Estoque insuficiente para '{$produto}': solicitado {$solicitado}, disponível {$disponivel}."
        );
    }
}

// Granularidade de captura — do mais específico ao mais genérico
try {
    throw new PedidoNaoEncontradoException(999);
} catch (PedidoNaoEncontradoException $e) {
    // Mais específico — trata só "pedido não encontrado"
    echo "Pedido ID: " . $e->pedidoId . "\n";
} catch (PedidoException $e) {
    // Médio — trata qualquer erro de pedido
} catch (AppException $e) {
    // Amplo — trata qualquer erro da aplicação
}

Encadeamento de exceções — preservando a causa

Quando você captura uma exceção de baixo nível (erro de PDO, falha de rede) e relança uma exceção de alto nível mais significativa, é fundamental preservar a exceção original como causa usando o parâmetro $previous. Isso mantém o stack trace completo para depuração sem vazar detalhes de implementação para o código cliente.

A regra é clara: o cliente da API vê a mensagem de domínio. O log registra a causa técnica. Nunca inverta isso.

<?php
declare(strict_types=1);

class PedidoRepository
{
    public function buscarPorId(int $id): array
    {
        try {
            // Simula uma falha de banco de dados
            throw new \PDOException("SQLSTATE[42S02]: Table 'pedidos' doesn't exist");

        } catch (\PDOException $pdoException) {
            // Traduz exceção de infra → exceção de domínio
            // $pdoException como $previous — preserva o contexto completo
            throw new PedidoNaoEncontradoException($id, $pdoException);
        }
    }
}

try {
    (new PedidoRepository())->buscarPorId(42);
} catch (PedidoNaoEncontradoException $e) {
    echo "Mensagem: "  . $e->getMessage()  . "\n";
    echo "Código: "    . $e->getCode()    . "\n";
    echo "Pedido ID: " . $e->pedidoId    . "\n";

    // getPrevious() retorna a PDOException original
    // Deve ser logada — nunca exposta ao cliente
    $causa = $e->getPrevious();
    if ($causa) {
        error_log("Causa técnica: " . $causa->getMessage());
    }
}
// Mensagem: Pedido #42 não encontrado.
// Código: 404
// Pedido ID: 42

Finally — garantindo limpeza de recursos

O bloco finally executa sempre, independente de exceção ser lançada, capturada ou não capturada — inclusive quando há return dentro do try. É o lugar correto para liberar conexões, arquivos e locks. Nunca coloque esse código no try, pois uma exceção impediria sua execução.

<?php
declare(strict_types=1);

function processarArquivo(string $caminho): array
{
    $handle = null;
    try {
        $handle = fopen($caminho, 'r');
        if ($handle === false) {
            throw new \RuntimeException("Não foi possível abrir: {$caminho}");
        }
        $linhas = [];
        while (($linha = fgets($handle)) !== false) {
            $linhas[] = trim($linha);
        }
        return $linhas;

    } catch (\RuntimeException $e) {
        echo "Erro: " . $e->getMessage() . "\n";
        return [];

    } finally {
        // SEMPRE executa — com exceção, sem exceção, com return no try/catch
        if (is_resource($handle)) {
            fclose($handle);
            echo "Arquivo fechado.\n";
        }
    }
}

// O finally executa mesmo quando há return no try
function demoFinally(): string
{
    try {
        return "valor do try";   // o return é preparado...
    } finally {
        echo "finally executou antes do return ser entregue!\n";
        // Se houvesse return aqui, ele SOBRESCREVERIA o do try
    }
}
echo demoFinally();
// finally executou antes do return ser entregue!
// valor do try

Handlers globais — a última linha de defesa

Qualquer exceção não capturada em nenhum try/catch sobe até o handler global registrado com set_exception_handler(). Esse handler formata a resposta de erro, registra no log, e decide o que mostrar ao usuário versus o que esconder. Em produção, nunca deve expor stack traces.

<?php
declare(strict_types=1);

use MeuApp\Exceptions\HttpException;
use MeuApp\Exceptions\ValidacaoException;
use MeuApp\Exceptions\AppException;

$ambiente = getenv('APP_ENV') ?: 'production';

// Registra o handler global para exceções não capturadas
set_exception_handler(function (\Throwable $e) use ($ambiente): void {

    // 1. Sempre registra no log — independente do ambiente
    error_log(sprintf(
        "[%s] %s: %s em %s:%d\n%s",
        date('Y-m-d H:i:s'),
        get_class($e),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString()
    ));

    // 2. Determina status HTTP e corpo baseados no tipo
    [$status, $corpo] = match (true) {
        $e instanceof ValidacaoException => [422, ['erros'    => $e->erros]],
        $e instanceof HttpException      => [$e->statusHttp, ['mensagem' => $e->getMessage()]],
        $e instanceof AppException       => [500, ['mensagem' => $e->getMessage()]],
        // Exceções inesperadas — não vaza detalhes em produção
        default => [500, $ambiente === 'development'
            ? ['mensagem' => $e->getMessage(), 'classe' => get_class($e), 'trace' => $e->getTrace()]
            : ['mensagem' => 'Erro interno do servidor. Tente novamente em instantes.']
        ],
    };

    // 3. Envia a resposta HTTP adequada
    http_response_code($status);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode(['erro' => $corpo], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
});

// Converte erros PHP (notices, warnings) em exceções — tratamento uniforme
set_error_handler(function (int $errno, string $msg, string $file, int $line): bool {
    // Respeita o operador @ (supressão de erros)
    if (error_reporting() === 0) return false;
    throw new \ErrorException($msg, $errno, $errno, $file, $line);
});

Boas práticas e antipadrões

Nunca capture para silenciar. Um catch vazio que engole a exceção sem log, sem retorno alternativo, sem nada — é o pior antipadrão possível. O erro desaparece, o sistema continua em estado inválido, e você não tem nenhuma pista do que aconteceu.

Não capture o que não sabe tratar. Se o código não sabe o que fazer com uma PDOException, não a capture ali — deixe subir para quem sabe. Capture somente no nível onde você tem contexto suficiente para tomar uma decisão significativa.

Use exceções para condições excepcionais, não para fluxo normal. buscarPorId() retornando null quando o registro não existe é fluxo normal — não é exceção. buscarPorId() lançando exceção quando o banco está inacessível é condição excepcional.

<?php
declare(strict_types=1);

// ❌ ANTIPADRÕES ───────────────────────────────────────────────────────

// 1. Catch vazio — engole o erro sem rastro
try {
    $db->buscar(42);
} catch (\Exception $e) {
    // silêncio total — o sistema continua em estado desconhecido
}

// 2. Re-lançar perdendo a causa original
try {
    $pdo->query($sql);
} catch (\PDOException $e) {
    // ❌ perde o stack trace original — $e some para sempre
    throw new \RuntimeException("Erro ao buscar dados");
}

// 3. Usar exceções para fluxo normal
try {
    $produto = $repo->buscarPorId($id); // lança se não encontrar
} catch (NaoEncontradoException $e) {
    // ❌ "não encontrado" é fluxo normal — use retorno nullable
    $produto = null;
}

// ✅ PADRÕES CORRETOS ──────────────────────────────────────────────────

// 1. Re-lançar preservando a causa
try {
    $pdo->query($sql);
} catch (\PDOException $e) {
    // ✅ $e fica disponível em getPrevious() para log e debug
    throw new PedidoNaoEncontradoException($id, $e);
}

// 2. Finally para limpeza garantida
$conexao = null;
try {
    $conexao = abrirConexao();
    $conexao->executar($sql);
} finally {
    // nullsafe para o caso de falhar no próprio abrirConexao()
    $conexao?->fechar();
}

// 3. Exceção com dados ricos — mensagem descritiva e properties estruturadas
throw new EstoqueInsuficienteException(
    produto:    'Teclado Mecânico',
    solicitado: 5,
    disponivel: 2,
);
// getMessage() → "Estoque insuficiente para 'Teclado Mecânico': solicitado 5, disponível 2."
// $e->produto, $e->solicitado, $e->disponivel acessíveis no handler

Resumo

Conceito O que aprendemos
Throwable Interface raiz — Error (bugs PHP) vs Exception (erros de aplicação)
catch (A|B $e) Múltiplos tipos num mesmo bloco — PHP 8+
Hierarquia própria Base abstrata por módulo + exceções específicas com readonly properties
$previous Preserva a causa original ao traduzir exceções de infra para domínio
getPrevious() Recupera a exceção que causou a atual — para log técnico
finally Executa sempre — local correto para limpeza de recursos
set_exception_handler() Handler global para exceções não capturadas
set_error_handler() Converte warnings/notices em ErrorException — tratamento uniforme
Catch vazio Antipadrão — nunca silenciar exceções sem log ou ação alternativa

Exercício da semana

  1. Crie uma hierarquia de exceções para um sistema de pagamentos: PagamentoException (base), CartaoRecusadoException (com código de recusa e últimos 4 dígitos), LimiteExcedidoException (com limite disponível e valor solicitado) e FraudeDetectadaException (com ID de transação). Cada exceção deve ter readonly properties e mensagem descritiva.
  2. Implemente um PagamentoService que pode lançar qualquer das exceções acima. Num ponto externo, trate cada tipo de forma diferente: CartaoRecusado → solicitar novo cartão; LimiteExcedido → sugerir parcelas; FraudeDetectada → bloquear conta e notificar segurança.
  3. Adicione encadeamento: quando um PDOException ocorre ao registrar o pagamento, envolva-o numa PagamentoException preservando a causa. Verifique com getPrevious() que o stack trace original está acessível.
  4. Implemente um handler global com set_exception_handler() que retorna JSON com status 402 para PagamentoException, e 500 (sem detalhes em produção, com trace em desenvolvimento) para \Throwable genérico.
  5. Desafio: crie um ExceptionHandler orientado a objetos com register(): void, render(\Throwable $e): array e report(\Throwable $e): void. Adicione suporte a renderers registráveis via addRenderer(string $classe, \Closure $renderer): void para que cada tipo de exceção tenha seu próprio formato de resposta.

Referências

Comentários

Mais em PHP

Herança, Interfaces e Traits
Herança, Interfaces e Traits

No artigo anterior aprendemos a criar classes com propriedades, m&eacute;todo...

Funções
Funções

At&eacute; agora escrevemos c&oacute;digo sequencial &mdash; uma instru&ccedi...

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

Orienta&ccedil;&atilde;o a Objetos &eacute; o paradigma que organiza o c&oacu...