PHP Exceções (try e catch)

Uma função descobre um problema que ela não sabe resolver: o cliente pediu 5 bolos e só há 2. Ela não deve mostrar mensagem nenhuma (talvez esteja num site, talvez num aplicativo), nem devolver um número mágico como -1 que alguém vai esquecer de conferir. O jeito moderno é lançar uma exceção: "aconteceu isto, quem me chamou que decida". Quem chamou captura a exceção e trata o problema do jeito certo para aquele lugar.

Você vai aprender
  • Lançar uma exceção com throw new Exception("…").
  • O que acontece quando ninguém captura (o Fatal error: Uncaught …).
  • Capturar com try/catch, e o caminho que o programa percorre.
  • finally: o código que roda sempre.
  • Throwable × Exception × Error: quem captura o quê.
  • Vários catch, e vários tipos num catch só com |.
  • O que tem dentro de uma exceção: getMessage, getCode, getLine, getTraceAsString, getPrevious.
  • throw como expressão (PHP 8.0+).

Antes, leia Tipos de Erro, onde você viu que muitos erros do PHP 8 já são exceções (TypeError, DivisionByZeroError…). Nesta aula, a lojinha da Ana lança e captura as suas.


A ideia: o alarme e quem atende

Pense na cozinha da lojinha. O ajudante vai buscar ovos e descobre que acabaram. Ele não sai da cozinha para ligar para o fornecedor, nem inventa uma receita sem ovos: ele avisa ("acabaram os ovos!") e quem coordena decide (comprar, trocar o pedido, avisar o cliente). Se ninguém ouvir o aviso, a cozinha para.

  • Lançar (throw, "atirar") = dar o aviso.
  • Capturar (catch, "pegar") = ouvir o aviso e decidir.
  • Exceção = o aviso em si, um objeto com a mensagem e onde aconteceu.

Lançar: throw

<?php
function vender(int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new Exception("Estoque insuficiente: pediu $quantidade, tem $estoque");
    }
    return $estoque - $quantidade;
}

$resto = vender(10, 3);
echo "Restam $resto\n";
$resto = vender(2, 5);
echo "Restam $resto\n";
echo "Fim\n";
throw
"Lance". Interrompe a função na hora: nada depois do throw é executado, nem o return.
new Exception(…)
Cria o objeto exceção. Exception é a classe de exceção mais geral do PHP (na próxima aula você cria as suas).
"Estoque insuficiente: …"
A mensagem: escreva para quem vai ler o registro de erros, com os valores que ajudam a entender.
$ cd exc
$ php lancar.php; echo "código: $?"
Restam 7

Fatal error: Uncaught Exception: Estoque insuficiente: pediu 5, tem 2 in /home/ana/exc/lancar.php:5
Stack trace:
#0 /home/ana/exc/lancar.php(12): vender(2, 5)
#1 {main}
  thrown in /home/ana/exc/lancar.php on line 5
código: 255

A primeira venda funcionou. Na segunda, a exceção foi lançada e ninguém capturou. Uma exceção não capturada "sobe" por todas as funções até o topo do programa e, lá, o PHP a transforma num erro fatal. Lendo a mensagem:

PedaçoSignifica
Uncaught Exception"Exceção não capturada", do tipo Exception.
Estoque insuficiente: pediu 5, tem 2A mensagem que você escreveu.
in /home/ana/exc/lancar.php:5Onde foi criada (a linha do throw new).
Stack trace:"Rastro da pilha": o caminho de chamadas até ali, do mais recente para o mais antigo.
#0 /home/ana/exc/lancar.php(12): vender(2, 5)A linha 12 chamou vender com os argumentos 2, 5.
#1 {main}O programa principal (fora de funções).

Capturar: try e catch

<?php
// … a mesma função vender …

try {
    $resto = vender(10, 3);
    echo "Restam $resto\n";
    $resto = vender(2, 5);
    echo "Esta linha não roda\n";
} catch (Exception $e) {
    echo "Não deu: ", $e->getMessage(), "\n";
}
echo "Fim\n";
try { … }
"Tente": o código que pode lançar uma exceção.
catch (Exception $e)
"Capture exceções do tipo Exception e guarde-a em $e". O nome $e é costume; pode ser qualquer um.
{ … }
O que fazer com o problema. Aqui, mostrar a mensagem.
$e->getMessage()
Devolve o texto passado ao new Exception(…).
$ php try.php
Restam 7
Não deu: Estoque insuficiente: pediu 5, tem 2
Fim                                  « o programa continuou depois do catch

Siga o caminho do programa:

  1. Entra no try. A primeira venda dá certo e mostra Restam 7.
  2. A segunda venda lança a exceção. O PHP abandona o resto do try (a linha "Esta linha não roda" é pulada) e procura um catch que sirva.
  3. O catch (Exception $e) serve: o bloco dele roda.
  4. Depois do catch, o programa segue normalmente: Fim.

Se nenhuma exceção acontecer, o catch é simplesmente ignorado.

Missão: o programa morre na segunda venda. Envolva as vendas num try/catch que mostre Pedido recusado: seguido da mensagem da exceção, para que o Fim apareça.

<?php
function vender(int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new Exception("Estoque insuficiente: pediu $quantidade, tem $estoque");
    }
    return $estoque - $quantidade;
}

$resto = vender(10, 3);
echo "Restam $resto\n";
$resto = vender(2, 5);
echo "Restam $resto\n";
echo "Fim\n";
<?php
function vender(int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new Exception("Estoque insuficiente: pediu $quantidade, tem $estoque");
    }
    return $estoque - $quantidade;
}

try {
    $resto = vender(10, 3);
    echo "Restam $resto\n";
    $resto = vender(2, 5);
    echo "Restam $resto\n";
} catch (Exception $e) {
    echo "Pedido recusado: " . $e->getMessage() . "\n";
}
echo "Fim\n";

finally: o que roda sempre

Às vezes há uma "arrumação" que precisa acontecer com ou sem erro: fechar um arquivo, liberar uma trava, fechar o caixa. O bloco finally ("finalmente") roda sempre, depois do try e do catch:

<?php
function processar(int $qtd): void
{
    echo "Abrindo o caixa\n";
    try {
        if ($qtd <= 0) {
            throw new Exception("Quantidade inválida: $qtd");
        }
        echo "Vendidos: $qtd\n";
    } catch (Exception $e) {
        echo "Erro: ", $e->getMessage(), "\n";
    } finally {
        echo "Fechando o caixa\n";
    }
}
processar(3);
echo "---\n";
processar(0);
$ php finally.php
Abrindo o caixa
Vendidos: 3
Fechando o caixa           « sem erro: try → finally
---
Abrindo o caixa
Erro: Quantidade inválida: 0
Fechando o caixa           « com erro: try → catch → finally

O finally roda até quando o try faz return, e até quando a exceção não é capturada ali (ela continua subindo depois do finally):

<?php
function teste(): string
{
    try {
        return "do try";
    } finally {
        echo "o finally roda mesmo com return\n";
    }
}
echo teste(), "\n";
$ php finally-return.php
o finally roda mesmo com return
do try

Repare que esse try não tem catch: try + finally sozinhos também valem.

Missão: hoje o caixa só é fechado quando a venda dá certo. Mova o echo "Fechando o caixa\n"; para um bloco finally, para ele aparecer nas três chamadas.

<?php
function processar(int $qtd): void
{
    echo "Abrindo o caixa\n";
    try {
        if ($qtd <= 0) {
            throw new Exception("Quantidade inválida: $qtd");
        }
        echo "Vendidos: $qtd\n";
        echo "Fechando o caixa\n";
    } catch (Exception $e) {
        echo "Erro: ", $e->getMessage(), "\n";
    }
}
processar(3);
processar(0);
processar(-1);
<?php
function processar(int $qtd): void
{
    echo "Abrindo o caixa\n";
    try {
        if ($qtd <= 0) {
            throw new Exception("Quantidade inválida: $qtd");
        }
        echo "Vendidos: $qtd\n";
    } catch (Exception $e) {
        echo "Erro: ", $e->getMessage(), "\n";
    } finally {
        echo "Fechando o caixa\n";
    }
}
processar(3);
processar(0);
processar(-1);

Exception, Error e Throwable

Lembra da aula anterior? No PHP 8 há duas famílias de coisas que podem ser lançadas:

FamíliaQuem lançaExemplos
ExceptionO seu código e as bibliotecas, para problemas "esperados" (dado inválido, arquivo ruim, regra de negócio)Exception, InvalidArgumentException, RuntimeException, JsonException
ErrorO próprio PHP, para erros de programaçãoTypeError, ValueError, DivisionByZeroError, função inexistente

As duas são "filhas" da interface Throwable ("lançável"). E um catch só pega o tipo que ele nomeia e os filhos dele. Por isso, catch (Exception $e) não pega um Error:

<?php
try {
    echo intdiv(10, 0);
} catch (Exception $e) {
    echo "Peguei: ", $e->getMessage(), "\n";
}
echo "Fim\n";
$ php exception-error.php

Fatal error: Uncaught DivisionByZeroError: Division by zero in /home/ana/exc/exception-error.php:3
Stack trace:
#0 /home/ana/exc/exception-error.php(3): intdiv(10, 0)
#1 {main}
  thrown in /home/ana/exc/exception-error.php on line 3

O catch foi ignorado, porque DivisionByZeroError é da família Error. Para pegar qualquer coisa, capture Throwable:

<?php
try {
    echo intdiv(10, 0);
} catch (Throwable $e) {
    echo "Peguei ", get_class($e), ": ", $e->getMessage(), "\n";
}
var_dump($e instanceof Error, $e instanceof Exception, $e instanceof Throwable);
$ php throwable.php
Peguei DivisionByZeroError: Division by zero
bool(true)          « é um Error
bool(false)         « não é uma Exception
bool(true)          « e todo mundo é Throwable

get_class($e) diz a classe exata da exceção; instanceof ("é instância de") pergunta se ela pertence a uma família.

Qual capturar?

Capture o tipo mais específico que você sabe tratar (JsonException, InvalidArgumentException). Capturar Throwable esconde erros de programação, que deveriam aparecer e ser consertados. Ele é útil num ponto só, "lá no alto" do programa, para mostrar uma página de erro amigável e registrar o problema.


Vários tipos de exceção

Vários catch

Um try pode ter vários catch, um para cada tipo, cada um com seu tratamento. O PHP testa de cima para baixo e usa o primeiro que servir:

<?php
function dividir_conta(float $total, int $pessoas): float
{
    if ($total < 0) {
        throw new InvalidArgumentException('Total negativo');
    }
    return $total / $pessoas;
}
foreach ([[90, 3], [-5, 2], [90, 0]] as [$t, $p]) {
    try {
        $parte = dividir_conta($t, $p);
        echo "Cada um paga $parte\n";
    } catch (InvalidArgumentException $e) {
        echo "Valor errado: ", $e->getMessage(), "\n";
    } catch (DivisionByZeroError $e) {
        echo "Ninguém para pagar: ", $e->getMessage(), "\n";
    } catch (Throwable $e) {
        echo "Outro problema: ", $e->getMessage(), "\n";
    }
}
$ php varios.php
Cada um paga 30
Valor errado: Total negativo
Ninguém para pagar: Division by zero

Como o primeiro que serve ganha, ponha os tipos específicos primeiro e os gerais depois. Ao contrário, o geral "rouba" tudo:

<?php
try {
    throw new InvalidArgumentException('x');
} catch (Exception $e) {
    echo "Caiu no catch de Exception\n";
} catch (InvalidArgumentException $e) {
    echo "Caiu no catch específico\n";
}
$ php ordem.php
Caiu no catch de Exception        « InvalidArgumentException é filha de Exception: o primeiro serviu

Vários tipos num catch só: |

Quando o tratamento é o mesmo para tipos diferentes, junte-os com | ("ou"):

<?php
function ler_pedido(string $json): array
{
    $dados = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
    if (!isset($dados['quantidade'])) {
        throw new InvalidArgumentException('Falta a quantidade');
    }
    return $dados;
}

foreach (['{"quantidade": 2}', '{"quantidade": ', '{"produto": "bolo"}', '[1, 2]'] as $json) {
    try {
        $p = ler_pedido($json);
        echo "OK: {$p['quantidade']} unidade(s)\n";
    } catch (JsonException | InvalidArgumentException $e) {
        echo "Pedido recusado (", get_class($e), "): ", $e->getMessage(), "\n";
    }
}
$ php multi.php
OK: 2 unidade(s)
Pedido recusado (JsonException): Syntax error                       « o JSON quebrado (aula JSON)
Pedido recusado (InvalidArgumentException): Falta a quantidade
Pedido recusado (InvalidArgumentException): Falta a quantidade

As exceções prontas do PHP

Antes de criar as suas (próxima aula), saiba que o PHP já tem várias, com nomes que explicam o problema. As mais usadas:

ClasseUse quando…
InvalidArgumentExceptionUm argumento recebido é inválido (quantidade negativa, e-mail vazio).
DomainExceptionO valor não faz sentido no assunto do programa (um desconto de 150%).
LengthExceptionTamanho errado (CPF com 9 dígitos).
RangeException / OutOfRangeExceptionValor fora da faixa permitida (nota 11) / índice que não existe.
RuntimeExceptionAlgo deu errado durante a execução e não é culpa do código (disco cheio, serviço fora do ar).
LogicExceptionErro de lógica do programa, que deveria ser consertado no código.
UnexpectedValueExceptionUma função devolveu um valor inesperado.

O que tem dentro de uma exceção

A exceção é um objeto com bastante informação. Os métodos (todos começam com get, "pegar"):

<?php
function validar(int $nota): void
{
    if ($nota < 0 || $nota > 10) {
        throw new RangeException("Nota fora da faixa: $nota", 42);
    }
}
function lancar_notas(array $notas): void
{
    foreach ($notas as $n) {
        validar($n);
    }
}
try {
    lancar_notas([7, 9, 11]);
} catch (RangeException $e) {
    echo "Mensagem: ", $e->getMessage(), "\n";
    echo "Código:   ", $e->getCode(), "\n";
    echo "Arquivo:  ", $e->getFile(), "\n";
    echo "Linha:    ", $e->getLine(), "\n";
    echo "Rastro:\n", $e->getTraceAsString(), "\n";
}
$ php metodos.php
Mensagem: Nota fora da faixa: 11
Código:   42                         « o 2º argumento do new (padrão: 0)
Arquivo:  /home/ana/exc/metodos.php
Linha:    5                          « onde a exceção foi criada
Rastro:
#0 /home/ana/exc/metodos.php(11): validar(11)
#1 /home/ana/exc/metodos.php(15): lancar_notas(Array)
#2 {main}
MétodoDevolve
getMessage()A mensagem.
getCode()Um código numérico opcional (o 2º argumento do construtor).
getFile() / getLine()Arquivo e linha onde a exceção foi criada.
getTraceAsString()O rastro de chamadas, como texto (ótimo para o registro de erros).
getTrace()O mesmo rastro, como array.
getPrevious()A exceção "anterior", que causou esta (ou null).

E se você der echo na exceção inteira, o PHP mostra tudo junto, no mesmo formato do erro fatal:

$ php tostring.php      « catch (LogicException $e) { echo $e, "\n"; }
LogicException: Desconto maior que o preço in /home/ana/exc/tostring.php:3
Stack trace:
#0 {main}

Uma exceção causada por outra: getPrevious

Às vezes você captura uma exceção "técnica" e lança outra com uma mensagem mais útil, guardando a original como causa (o 3º argumento do construtor):

<?php
function carregar_config(string $arquivo): array
{
    try {
        return json_decode(file_get_contents($arquivo), true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        throw new RuntimeException("Configuração inválida em $arquivo", 0, $e);
    }
}
file_put_contents('config.json', '{"frete": 12,5}');
try {
    carregar_config('config.json');
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
    echo "Causa: ", $e->getPrevious()->getMessage(), "\n";
}
$ php previous.php
Configuração inválida em config.json
Causa: Syntax error                   « 12,5 não é número em JSON

Capturar, anotar e relançar

Um catch pode fazer alguma coisa (como registrar) e lançar a mesma exceção de novo com throw $e;, para quem estiver acima decidir:

<?php
function salvar(): void
{
    try {
        throw new RuntimeException('Disco cheio');
    } catch (RuntimeException $e) {
        error_log('Falha ao salvar: ' . $e->getMessage());
        throw $e;
    }
}
salvar();
$ php rethrow.php
Falha ao salvar: Disco cheio         « o error_log (no terminal, sem arquivo de log, sai na tela)

Fatal error: Uncaught RuntimeException: Disco cheio in /home/ana/exc/rethrow.php:5
Stack trace:
#0 /home/ana/exc/rethrow.php(11): salvar()
#1 {main}
  thrown in /home/ana/exc/rethrow.php on line 5

Novidades do PHP 8

throw como expressão PHP 8.0+

Antes, throw era uma instrução e só podia ficar sozinho numa linha. Desde o PHP 8.0 ele é uma expressão, e cabe em lugares como depois do ??, num ternário ou numa arrow function:

<?php
function preco(array $produto): float
{
    return $produto['preco'] ?? throw new InvalidArgumentException("Produto {$produto['nome']} sem preço");
}
echo preco(['nome' => 'Bolo', 'preco' => 35.0]), "\n";
try {
    echo preco(['nome' => 'Torta']), "\n";
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}
$desconto = fn(float $p) => $p > 0 ? $p * 0.9 : throw new ValueError('Preço deve ser positivo');
echo $desconto(10), "\n";
$ php expressao.php
35
Produto Torta sem preço
9

Lê-se: "devolva o preço; se não houver (??), lance a exceção". Em versões antigas, seria um if (!isset(…)) { throw …; } antes do return.

catch sem variável PHP 8.0+

Se você não vai usar o $e, pode omiti-lo:

<?php
try {
    $dados = json_decode('{x}', flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
    echo "JSON inválido, usando valores padrão\n";
    $dados = [];
}
$ php semvar.php
JSON inválido, usando valores padrão

Exceção × devolver false

Muitas funções antigas do PHP avisam o erro devolvendo false (como file_get_contents). Por que as funções novas, e as suas, preferem exceções?

Devolver false/nullLançar exceção
Se quem chamou esquecer de conferirO programa segue com um valor errado e quebra longe daliO programa para com uma mensagem clara, na linha certa
Explicar o motivoNão dá (é só false)Mensagem, código, tipo, causa
Tratar em outro lugarCada nível de função precisa repassar o falseA exceção sobe sozinha até quem tiver um catch
Quando usarQuando "não achei" é um resultado normal (procurar um item numa lista)Quando é um problema de verdade

Quando dá errado

"Fatal error: Uncaught …"
Fatal error: Uncaught Exception: Estoque insuficiente: pediu 5, tem 2 in /home/ana/exc/lancar.php:5

O que significa: uma exceção foi lançada e nenhum catch no caminho a capturou.

Como consertar: veja no Stack trace qual chamada levou ao problema e coloque um try/catch no nível que sabe o que fazer (mostrar uma mensagem ao cliente, por exemplo). Ou conserte a causa, se for erro de programação.

O catch não pegou a exceção

O que significa: o tipo no catch não é o da exceção nem um "pai" dela. O caso clássico: catch (Exception $e) com um TypeError ou DivisionByZeroError, que são da família Error.

Como consertar: capture o tipo certo (a mensagem de erro diz qual: Uncaught DivisionByZeroError), ou Throwable se for mesmo para pegar tudo.

"Can only throw objects"
$ php throwstring.php      « throw "Estoque insuficiente";

Fatal error: Uncaught Error: Can only throw objects in /home/ana/exc/throwstring.php:2
Stack trace:
#0 {main}
  thrown in /home/ana/exc/throwstring.php on line 2

O que significa: "só é possível lançar objetos". Um texto não é uma exceção.

Como consertar: throw new Exception("Estoque insuficiente");.

Um catch vazio "engolindo" erros

try { … } catch (Throwable $e) { } faz qualquer problema sumir sem deixar rastro. O programa continua com dados pela metade e ninguém sabe por quê.

Como consertar: todo catch deve fazer algo: tratar, registrar (error_log($e)) ou relançar.


Referência

SintaxeO que faz
throw new Exception($msg, $codigo, $anterior);Lança uma exceção.
try { … } catch (Tipo $e) { … }Captura exceções do tipo (e filhos).
catch (TipoA | TipoB $e)Captura qualquer um dos tipos.
catch (Tipo) 8.0+Captura sem guardar em variável.
finally { … }Roda sempre, com ou sem exceção.
throw $e;Relança a exceção capturada.
$x ?? throw new … 8.0+throw como expressão.
Throwable → Exception / ErrorAs duas famílias.
getMessage, getCode, getFile, getLine, getTraceAsString, getPreviousInformações da exceção.

Exercícios

1. Lançar

Complete para lançar uma exceção com a mensagem "Sem estoque":

("Sem estoque");

2. Pegar tudo

Complete para capturar exceções e erros do PHP:

} catch ( $e) {

3. A mensagem

Complete para mostrar a mensagem da exceção:

echo $e->;

4. Num try, a linha 2 lança uma exceção que é capturada. O que acontece com a linha 3 do try?

5. catch (Exception $e) captura um TypeError?

6. Quando o bloco finally roda?

Missão final: complete a função ler_linha. Ela recebe um texto como "bolo;3" e devolve ['bolo', 3]. Se faltar a quantidade, lance InvalidArgumentException com Falta a quantidade; se a quantidade não for um número inteiro positivo, lance InvalidArgumentException com Quantidade inválida: …. No laço, capture e mostre Linha N recusada: mensagem, e no fim mostre o total das linhas válidas (Total: 8 unidade(s)). Dica: ctype_digit('3') é true; ctype_digit('-2') e ctype_digit('abc') são false.

<?php
function ler_linha(string $linha): array
{
    $partes = explode(';', $linha);
    // seu código aqui
}

$pedido = ['bolo;3', 'torta;abc', 'brigadeiro;-2', 'pudim', 'cocada;5'];
$total = 0;
foreach ($pedido as $i => $linha) {
    [$produto, $qtd] = ler_linha($linha);
    echo "$produto: $qtd unidade(s)\n";
    $total += $qtd;
}
echo "Total: $total unidade(s)\n";
<?php
function ler_linha(string $linha): array
{
    $partes = explode(';', $linha);
    $qtd = $partes[1] ?? throw new InvalidArgumentException('Falta a quantidade');
    if (!ctype_digit($qtd) || (int) $qtd === 0) {
        throw new InvalidArgumentException("Quantidade inválida: $qtd");
    }
    return [$partes[0], (int) $qtd];
}

$pedido = ['bolo;3', 'torta;abc', 'brigadeiro;-2', 'pudim', 'cocada;5'];
$total = 0;
foreach ($pedido as $i => $linha) {
    try {
        [$produto, $qtd] = ler_linha($linha);
        echo "$produto: $qtd unidade(s)\n";
        $total += $qtd;
    } catch (InvalidArgumentException $e) {
        echo "Linha " . ($i + 1) . " recusada: " . $e->getMessage() . "\n";
    }
}
echo "Total: $total unidade(s)\n";

Resumo

  • throw new Exception("mensagem") interrompe a função e "sobe" até um catch; sem nenhum, vira Fatal error: Uncaught ….
  • try { … } catch (Tipo $e) { … }: se algo no try lançar, o resto do try é pulado e o catch que servir roda.
  • finally roda sempre.
  • Throwable é a raiz; Exception é do seu código, Error é do PHP. catch (Exception) não pega Error.
  • Vários catch (do específico ao geral) ou catch (A | B $e).
  • getMessage, getCode, getLine, getTraceAsString, getPrevious contam o que houve.
  • No PHP 8, throw é expressão (?? throw …) e o catch pode vir sem variável.

Próxima aula: Criar suas Exceções, como a EstoqueInsuficienteException da lojinha.