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
- PHP Manual — Exceptions — Documentação oficial de exceções em PHP: sintaxe, herança, finally, exceções encadeadas.
- PHP Manual — Throwable — Referência completa da interface Throwable com todos os métodos: getMessage, getCode, getFile, getLine, getTrace, getPrevious.
- PHP Manual — SPL Exceptions — Hierarquia completa de exceções da SPL com descrição de quando usar cada subclasse de RuntimeException e LogicException.
- MARTIN, R.C. — Clean Code, Cap. 7: Error Handling. O'Reilly — Por que usar exceções em vez de códigos de retorno e o princípio de não retornar null.
- Laravel — Exception Handling — Como o Laravel estrutura seu handler de exceções: register(), renderable callbacks por tipo e integração com o kernel HTTP.
- PHP: The Right Way — Exceptions — Boas práticas consolidadas pela comunidade PHP sobre exceções.
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();