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
-
Exceções — Manual oficial do PHP https://www.php.net/manual/pt_BR/language.exceptions.php Documentação completa do sistema de exceções, incluindo
finally, cadeias de exceção e a interfaceThrowable. -
Exceções predefinidas — Manual oficial do PHP https://www.php.net/manual/pt_BR/spl.exceptions.php Lista completa das exceções da SPL com descrição de quando usar cada uma.
-
PHP: The Right Way — Exceptions https://phptherightway.com/#exceptions Seção sobre boas práticas com exceções, incluindo quando usar e quando evitar.
-
MARTIN, Robert C. Clean Code. Prentice Hall, 2008. Capítulo 7: Error Handling. https://www.oreilly.com/library/view/clean-code-a/9780136083238/ O capítulo mais relevante do livro para este artigo. Trata de usar exceções em vez de códigos de retorno, não engolir exceções e separar a lógica de negócio do tratamento de erros.
-
Fowler, Martin. Refactoring, 2ª ed. Capítulo: Replace Error Code with Exception. https://refactoring.com/catalog/replaceErrorCodeWithException.html Padrão de refatoração que descreve exatamente como migrar de códigos de retorno (
false,-1) para exceções.
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.