Exceções Avançadas

Exceções Avançadas

Uma hierarquia de exceções bem desenhada diz ao chamador o que ele pode tratar e o que deve deixar subir. A árvore Throwable do PHP, o encadeamento que preserva a causa original, o finally que garante a limpeza e os handlers globais que evitam a tela branca em produção.
PHP

21 min de leitura

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

Há uma pergunta que resolve a maior parte das dúvidas sobre exceção: quem vai tratar isso, e com que informação? Se ninguém tem como reagir, capturar só esconde o problema — melhor deixar subir até o handler global, que registra e devolve uma resposta decente. Se alguém tem, então a exceção precisa carregar o contexto necessário para essa decisão, e é aí que o tipo próprio e o encadeamento pagam por si. O catch vazio, que aparece em todo sistema antigo, é a resposta errada para as duas perguntas ao mesmo tempo.

Fontes e leituras recomendadas

Exercícios

Exercício 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.

Ver resposta

✓ Resposta: As propriedades readonly são o que diferencia isso de "exceção com string": o catch recebe os dados do erro e pode agir — sugerir parcelas, decidir se vale retentar — sem precisar reinterpretar a mensagem com regex.

<?php

declare(strict_types=1);

// Abstrata: ninguém lança "um erro de pagamento genérico" — sempre um caso
// concreto. Mas dá para capturar todos de uma vez.
abstract class PagamentoException extends RuntimeException
{
    abstract public function contexto(): array;
}

final class CartaoRecusadoException extends PagamentoException
{
    public function __construct(
        public readonly string $codigoRecusa,
        public readonly string $ultimos4,
    ) {
        parent::__construct(
            "Cartão final {$ultimos4} recusado pelo emissor (código {$codigoRecusa})."
        );
    }

    public function contexto(): array
    {
        return ['codigo_recusa' => $this->codigoRecusa, 'ultimos4' => $this->ultimos4];
    }

    // Recusa por saldo é temporária; cartão bloqueado, não.
    public function valeTentarDeNovo(): bool
    {
        return in_array($this->codigoRecusa, ['51', '61', '65'], true);
    }
}

final class LimiteExcedidoException extends PagamentoException
{
    public function __construct(
        public readonly float $limiteDisponivel,
        public readonly float $valorSolicitado,
    ) {
        parent::__construct(sprintf(
            'Limite excedido: solicitados R$ %s, disponíveis R$ %s.',
            number_format($valorSolicitado, 2, ',', '.'),
            number_format($limiteDisponivel, 2, ',', '.'),
        ));
    }

    public function contexto(): array
    {
        return ['disponivel' => $this->limiteDisponivel, 'solicitado' => $this->valorSolicitado];
    }

    /** Menor número de parcelas que cabe no limite. */
    public function parcelasSugeridas(int $maximo = 12): int
    {
        if ($this->limiteDisponivel <= 0) {
            return $maximo;
        }

        return min($maximo, (int) ceil($this->valorSolicitado / $this->limiteDisponivel));
    }
}

final class FraudeDetectadaException extends PagamentoException
{
    public function __construct(
        public readonly string $transacaoId,
        public readonly int $score = 0,
    ) {
        parent::__construct(
            "Transação {$transacaoId} bloqueada por suspeita de fraude (score {$score})."
        );
    }

    public function contexto(): array
    {
        return ['transacao_id' => $this->transacaoId, 'score' => $this->score];
    }
}

Exercício 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.

Ver resposta

✓ Resposta: A ordem dos catch é a armadilha clássica: o PHP usa o primeiro bloco compatível, não o mais específico. Com PagamentoException no topo, os três tratamentos específicos viram código morto — e sem aviso nenhum.

<?php

declare(strict_types=1);

final class PagamentoService
{
    public function __construct(
        private readonly PDO $pdo,
        private readonly float $limiteCliente,
    ) {}

    public function cobrar(string $cartao, float $valor): string
    {
        $score = $this->avaliarRisco($cartao, $valor);

        if ($score >= 80) {
            throw new FraudeDetectadaException(
                transacaoId: bin2hex(random_bytes(8)),
                score: $score,
            );
        }

        if ($valor > $this->limiteCliente) {
            throw new LimiteExcedidoException($this->limiteCliente, $valor);
        }

        $recusa = $this->autorizar($cartao, $valor);

        if ($recusa !== null) {
            throw new CartaoRecusadoException($recusa, substr($cartao, -4));
        }

        return 'AUT-' . strtoupper(bin2hex(random_bytes(4)));
    }

    private function avaliarRisco(string $cartao, float $valor): int
    {
        return $valor > 10000 ? 90 : 10;
    }

    private function autorizar(string $cartao, float $valor): ?string
    {
        return str_ends_with($cartao, '0000') ? '51' : null;
    }
}

// ---------- No ponto externo: um catch por tipo ---------------------------
// A ordem importa: do mais específico para o mais genérico. Se
// PagamentoException viesse primeiro, os três blocos abaixo dela nunca
// seriam alcançados — a base captura todas as filhas.
try {
    echo $servico->cobrar('4111111111110000', 250.00), PHP_EOL;

} catch (CartaoRecusadoException $e) {
    echo "✗ {$e->getMessage()}", PHP_EOL;

    echo $e->valeTentarDeNovo()
        ? '  → Tente novamente ou use outro cartão.'
        : '  → Informe outro cartão para continuar.', PHP_EOL;

} catch (LimiteExcedidoException $e) {
    echo "✗ {$e->getMessage()}", PHP_EOL;
    printf("  → Que tal parcelar em %dx?%s", $e->parcelasSugeridas(), PHP_EOL);

} catch (FraudeDetectadaException $e) {
    echo "✗ {$e->getMessage()}", PHP_EOL;

    $contaService->bloquear($e->transacaoId);
    $seguranca->notificar('fraude.detectada', $e->contexto());

    echo '  → Conta bloqueada; segurança acionada.', PHP_EOL;

} catch (PagamentoException $e) {
    // Rede de segurança: um tipo novo de erro de pagamento cai aqui em vez
    // de escapar como erro 500.
    echo '✗ Falha no pagamento: ', $e->getMessage(), PHP_EOL;
}

Exercício 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.

Ver resposta

✓ Resposta: O erro que essa técnica evita é o catch que troca a exceção por uma nova e perde o rastro — aí a mensagem diz "falhou ao registrar" e o trace aponta para a linha do throw, não para o INSERT que quebrou. Com getPrevious(), você tem os dois.

<?php

declare(strict_types=1);

final class RegistroPagamentoException extends PagamentoException
{
    public function __construct(
        public readonly string $autorizacao,
        \Throwable $causa,
    ) {
        // O 3º argumento de Exception é a causa. É ele que constrói a corrente.
        parent::__construct(
            "Pagamento {$autorizacao} autorizado, mas falhou ao registrar.",
            0,
            $causa,
        );
    }

    public function contexto(): array
    {
        return ['autorizacao' => $this->autorizacao];
    }
}

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

    public function registrar(string $autorizacao, float $valor): void
    {
        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO pagamentos (autorizacao, valor, criado_em)
                 VALUES (:aut, :valor, NOW())'
            );
            $stmt->execute([':aut' => $autorizacao, ':valor' => $valor]);

        } catch (PDOException $e) {
            // Envolver, nunca engolir: a camada de cima recebe um erro de
            // domínio ("não registrou"), e o erro técnico continua alcançável.
            throw new RegistroPagamentoException($autorizacao, $e);
        }
    }
}

// ---------- Verificando a corrente ---------------------------------------
try {
    $repositorio->registrar('AUT-9F2C', 250.00);

} catch (RegistroPagamentoException $e) {
    echo 'Mensagem de domínio: ', $e->getMessage(), PHP_EOL;

    $causa = $e->getPrevious();
    echo 'Causa técnica:       ', $causa::class, ' — ', $causa->getMessage(), PHP_EOL;
    echo 'SQLSTATE:            ', $causa->getCode(), PHP_EOL;

    // O trace original está inteiro na causa — foi ela que nasceu no ponto
    // do erro, com o arquivo e a linha do INSERT.
    echo 'Origem real:         ', $causa->getFile(), ':', $causa->getLine(), PHP_EOL;

    // Percorrer a corrente toda, de fora para dentro:
    for ($atual = $e; $atual !== null; $atual = $atual->getPrevious()) {
        printf("  %s: %s%s", $atual::class, $atual->getMessage(), PHP_EOL);
    }
}

// Mensagem de domínio: Pagamento AUT-9F2C autorizado, mas falhou ao registrar.
// Causa técnica:       PDOException — SQLSTATE[23000]: Duplicate entry...
// SQLSTATE:            23000

Exercício 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.

Ver resposta

✓ Resposta: Três detalhes que separam handler de brinquedo de handler de produção: o teste de headers_sent(), a mensagem genérica em produção (getMessage() de erro técnico vaza estrutura interna) e o register_shutdown_function — erro fatal não é Throwable e escaparia sem ele.

<?php

declare(strict_types=1);

// Vale para exceção NÃO capturada. Depois que ele roda, o script termina —
// não há como continuar a execução a partir dali.
set_exception_handler(static function (\Throwable $e): void {
    $ehDesenvolvimento = ($_ENV['APP_ENV'] ?? 'production') !== 'production';

    // headers_sent(): se algo já foi impresso, http_response_code não tem efeito
    // e o JSON sairia grudado na saída anterior.
    if (!headers_sent()) {
        header('Content-Type: application/json; charset=utf-8');
        http_response_code($e instanceof PagamentoException ? 402 : 500);
    }

    if ($e instanceof PagamentoException) {
        $corpo = [
            'erro'     => 'pagamento_recusado',
            'mensagem' => $e->getMessage(),   // seguro: escrita para o cliente
            'contexto' => $e->contexto(),
        ];
    } else {
        $corpo = [
            'erro'     => 'erro_interno',
            // Em produção, mensagem genérica: getMessage() de erro técnico
            // vaza nome de tabela, caminho de arquivo e às vezes credencial.
            'mensagem' => $ehDesenvolvimento
                ? $e->getMessage()
                : 'Erro interno. Tente novamente em instantes.',
        ];

        if ($ehDesenvolvimento) {
            $corpo['excecao'] = $e::class;
            $corpo['origem']  = $e->getFile() . ':' . $e->getLine();
            $corpo['trace']   = explode("\n", $e->getTraceAsString());

            if ($e->getPrevious() !== null) {
                $corpo['causa'] = $e->getPrevious()::class . ': '
                                . $e->getPrevious()->getMessage();
            }
        }
    }

    // O log recebe tudo, sempre — independente do ambiente.
    error_log(sprintf('[%s] %s em %s:%d',
        $e::class, $e->getMessage(), $e->getFile(), $e->getLine()));

    echo json_encode($corpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
});

// Erro fatal (E_ERROR) não passa pelo handler de exceção — só por este.
register_shutdown_function(static function (): void {
    $erro = error_get_last();

    if ($erro !== null && ($erro['type'] & (E_ERROR | E_PARSE | E_CORE_ERROR))) {
        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
            http_response_code(500);
        }
        echo json_encode(['erro' => 'erro_fatal']);
    }
});

throw new LimiteExcedidoException(500.00, 1200.00);
// HTTP 402
// {"erro":"pagamento_recusado","mensagem":"Limite excedido: solicitados
//  R$ 1.200,00, disponíveis R$ 500,00.","contexto":{...}}

Exercício 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.

Ver resposta

✓ Resposta: O truque que faz o registro por classe valer a pena está em candidatas(): subir a hierarquia com class_parents(). Sem isso, você precisaria registrar um renderer para cada exceção concreta; com isso, um renderer na base cobre a família inteira e os específicos ainda vencem.

<?php

declare(strict_types=1);

final class ExceptionHandler
{
    /** @var array<class-string, \Closure(\Throwable): array> */
    private array $renderers = [];

    /** @var class-string[] */
    private array $naoReportar = [];

    public function __construct(private readonly bool $debug = false) {}

    public function register(): void
    {
        set_exception_handler($this->handle(...));

        set_error_handler(static function (int $nivel, string $msg, string $arq, int $lin): bool {
            // Converte warning/notice em exceção: erro silencioso vira erro visível.
            if ((error_reporting() & $nivel) === 0) {
                return false;
            }
            throw new \ErrorException($msg, 0, $nivel, $arq, $lin);
        });
    }

    public function addRenderer(string $classe, \Closure $renderer): void
    {
        $this->renderers[$classe] = $renderer;
    }

    public function naoReportar(string ...$classes): void
    {
        foreach ($classes as $classe) {
            $this->naoReportar[] = $classe;
        }
    }

    public function handle(\Throwable $e): void
    {
        $this->report($e);

        $resposta = $this->render($e);

        if (!headers_sent()) {
            header('Content-Type: application/json; charset=utf-8');
            http_response_code($resposta['status'] ?? 500);
        }

        echo json_encode($resposta['corpo'] ?? $resposta, JSON_UNESCAPED_UNICODE);
    }

    public function render(\Throwable $e): array
    {
        // Procura da classe exata para as ancestrais: um renderer de
        // PagamentoException atende CartaoRecusadoException se não houver
        // um específico para ela.
        foreach ($this->candidatas($e) as $classe) {
            if (isset($this->renderers[$classe])) {
                return ($this->renderers[$classe])($e);
            }
        }

        return $this->renderPadrao($e);
    }

    public function report(\Throwable $e): void
    {
        foreach ($this->naoReportar as $ignorada) {
            if ($e instanceof $ignorada) {
                return;
            }
        }

        error_log(sprintf('[%s] %s em %s:%d',
            $e::class, $e->getMessage(), $e->getFile(), $e->getLine()));
    }

    /** @return string[] classe, pais e interfaces — nessa ordem */
    private function candidatas(\Throwable $e): array
    {
        return [
            $e::class,
            ...array_values(class_parents($e) ?: []),
            ...array_values(class_implements($e) ?: []),
        ];
    }

    private function renderPadrao(\Throwable $e): array
    {
        $corpo = ['erro' => 'erro_interno'];

        if ($this->debug) {
            $corpo += [
                'excecao'  => $e::class,
                'mensagem' => $e->getMessage(),
                'origem'   => $e->getFile() . ':' . $e->getLine(),
            ];
        } else {
            $corpo['mensagem'] = 'Erro interno.';
        }

        return ['status' => 500, 'corpo' => $corpo];
    }
}

// ---------- Uso -----------------------------------------------------------
$handler = new ExceptionHandler(debug: ($_ENV['APP_ENV'] ?? '') === 'local');

$handler->addRenderer(PagamentoException::class, static fn(PagamentoException $e): array => [
    'status' => 402,
    'corpo'  => ['erro' => 'pagamento', 'mensagem' => $e->getMessage(),
                 'contexto' => $e->contexto()],
]);

// Mais específico que o de cima: fraude não devolve 402, devolve 403.
$handler->addRenderer(FraudeDetectadaException::class, static fn(FraudeDetectadaException $e): array => [
    'status' => 403,
    'corpo'  => ['erro' => 'bloqueado', 'transacao' => $e->transacaoId],
]);

$handler->addRenderer(InvalidArgumentException::class, static fn(\Throwable $e): array => [
    'status' => 422,
    'corpo'  => ['erro' => 'validacao', 'mensagem' => $e->getMessage()],
]);

$handler->naoReportar(CartaoRecusadoException::class);   // ruído no log
$handler->register();
Comentários

Mais em PHP

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

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

A História do PHP: de script pessoal a pilar da web
A História do PHP: de script pessoal a pilar da web

De um script para contar visitas a um currículo até a linguagem que move boa…

Estruturas de Repetição
Estruturas de Repetição

Repetir é onde o PHP oferece mais caminhos para o mesmo destino: for quando o…