PHP Criar suas Exceções

Na aula anterior, a lojinha da Ana lançava new Exception("Estoque insuficiente…"). Funciona, mas quem captura só tem a mensagem para saber o que aconteceu, e não dá para tratar "estoque acabou" de um jeito e "cartão recusado" de outro. A solução é criar tipos de exceção próprios, com nomes que dizem o problema. São classes de uma ou poucas linhas, e são a porta de entrada mais suave para a orientação a objetos.

Você vai aprender
  • Criar uma exceção com class EstoqueInsuficienteException extends Exception {}.
  • Capturar só o seu tipo, deixando os outros passarem.
  • Dar um construtor à exceção, com a mensagem pronta e dados extras (parent::__construct).
  • Montar uma hierarquia: uma exceção "mãe" da loja e várias "filhas".
  • Estender as exceções prontas do PHP (InvalidArgumentException…).
  • Quando vale a pena criar uma exceção, e quando não.

Você precisa ter visto Exceções (try e catch). Esta aula usa duas ideias de orientação a objetos, explicadas aqui só o suficiente: classe (um "molde" de objetos) e herança (extends, "estende": uma classe nova que aproveita tudo de outra). Elas são estudadas a fundo em Classes e Objetos e Herança, no Nível 3.


A ideia: etiquetas nos avisos

Imagine que todos os avisos da cozinha fossem papeizinhos brancos com um texto. Para saber se é "acabou o ovo" ou "o forno quebrou", você precisa ler cada um. Agora imagine papeizinhos coloridos: amarelo é falta de ingrediente, vermelho é equipamento quebrado. Você separa só de olhar, e cada cor vai para a pessoa certa. Criar uma exceção própria é criar uma nova "cor" de aviso.


A exceção mais simples

<?php
class EstoqueInsuficienteException extends Exception
{
}

function vender(string $produto, int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new EstoqueInsuficienteException("Só há $estoque de $produto");
    }
    return $estoque - $quantidade;
}

try {
    vender('bolo de cenoura', 2, 5);
} catch (EstoqueInsuficienteException $e) {
    echo "Avise o cliente: ", $e->getMessage(), "\n";
}
vender('torta', 1, 3);
class
"Classe": vamos criar um novo tipo.
EstoqueInsuficienteException
O nome. Por costume, em CamelCase (cada palavra com maiúscula) e terminando em Exception. Diga o problema, não o lugar: EstoqueInsuficiente, e não ErroNaFuncaoVender.
extends Exception
"Estende Exception": a classe nova herda tudo da Exception (mensagem, código, linha, rastro, getMessage()…). É isso que permite lançá-la.
{ }
O corpo vazio: não precisamos acrescentar nada. A única novidade é o nome, e isso já é muito.
$ cd excp
$ php simples.php
Avise o cliente: Só há 2 de bolo de cenoura

Fatal error: Uncaught EstoqueInsuficienteException: Só há 1 de torta in /home/ana/excp/simples.php:9
Stack trace:
#0 /home/ana/excp/simples.php(19): vender('torta', 1, 3)
#1 {main}
  thrown in /home/ana/excp/simples.php on line 9

A primeira venda foi capturada pelo catch específico. A segunda, sem try, virou o erro fatal, e repare que a mensagem já diz o tipo: Uncaught EstoqueInsuficienteException. Quem ler o registro de erros entende o problema antes mesmo de ler a mensagem.

Missão: crie a classe EstoqueInsuficienteException (filha de Exception), lance-a na função no lugar da Exception genérica e troque o catch para capturar só ela.

<?php
// crie a classe aqui

function vender(string $produto, int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new Exception("Só há $estoque de $produto");
    }
    return $estoque - $quantidade;
}

try {
    vender('bolo', 2, 5);
} catch (Exception $e) {
    echo "Avise o cliente: ", $e->getMessage(), "\n";
}
<?php
class EstoqueInsuficienteException extends Exception
{
}

function vender(string $produto, int $estoque, int $quantidade): int
{
    if ($quantidade > $estoque) {
        throw new EstoqueInsuficienteException("Só há $estoque de $produto");
    }
    return $estoque - $quantidade;
}

try {
    vender('bolo', 2, 5);
} catch (EstoqueInsuficienteException $e) {
    echo "Avise o cliente: ", $e->getMessage(), "\n";
}

Um construtor: mensagem pronta e dados extras

Com a classe vazia, cada throw precisa montar a mensagem, e quem captura só recebe texto. Se o catch quiser saber quantos faltaram, teria que "ler" a mensagem. Melhor: a exceção guarda os dados e monta a mensagem sozinha. Para isso ela ganha um construtor (__construct), a função que roda quando você escreve new:

<?php
class EstoqueInsuficienteException extends Exception
{
    public function __construct(
        public readonly string $produto,
        public readonly int $pedido,
        public readonly int $disponivel,
    ) {
        parent::__construct("Estoque insuficiente de $produto: pediu $pedido, há $disponivel");
    }
}

try {
    throw new EstoqueInsuficienteException('brigadeiro', 50, 30);
} catch (EstoqueInsuficienteException $e) {
    echo $e->getMessage(), "\n";
    echo "Falta(m) ", $e->pedido - $e->disponivel, " {$e->produto}(s)\n";
    echo "Linha: ", $e->getLine(), "\n";
}
TrechoO que faz
public function __construct(…)O construtor: recebe os valores passados em new EstoqueInsuficienteException('brigadeiro', 50, 30). Veja Construtores.
public readonly string $produto,Recebe o parâmetro e já cria uma propriedade $produto com o valor (a "promoção de construtor", PHP 8.0+). public: dá para ler de fora ($e->produto). readonly (8.1+): ninguém pode mudar depois.
parent::__construct("…")Chama o construtor da classe mãe (Exception) com a mensagem montada. É ele que guarda a mensagem para o getMessage(). Não esqueça esta linha!
$e->pedido - $e->disponivelQuem captura usa os dados, sem precisar interpretar o texto.
$ php construtor.php
Estoque insuficiente de brigadeiro: pediu 50, há 30
Falta(m) 20 brigadeiro(s)
Linha: 14                     « getLine continua funcionando: herdado da Exception

Agora o throw ficou curto e sem chance de mensagem mal escrita: throw new EstoqueInsuficienteException($produto, $qtd, $estoque);.

Missão: a exceção já guarda $produto e $disponivel. Acrescente o dado $pedido (int) ao construtor, inclua-o na mensagem e, no catch, mostre Faltam 3 pudim(s) para o pedido da Bia calculando com as propriedades.

<?php
class EstoqueInsuficienteException extends Exception
{
    public function __construct(
        public readonly string $produto,
        public readonly int $disponivel,
    ) {
        parent::__construct("Só há $disponivel de $produto");
    }
}

try {
    throw new EstoqueInsuficienteException('pudim', 2);
} catch (EstoqueInsuficienteException $e) {
    echo $e->getMessage(), "\n";
}
<?php
class EstoqueInsuficienteException extends Exception
{
    public function __construct(
        public readonly string $produto,
        public readonly int $pedido,
        public readonly int $disponivel,
    ) {
        parent::__construct("Pediu $pedido de $produto, só há $disponivel");
    }
}

try {
    throw new EstoqueInsuficienteException('pudim', 5, 2);
} catch (EstoqueInsuficienteException $e) {
    echo $e->getMessage(), "\n";
    echo "Faltam ", $e->pedido - $e->disponivel, " {$e->produto}(s) para o pedido da Bia\n";
}

Uma família de exceções

A lojinha tem vários problemas possíveis: produto que não existe, estoque insuficiente, pagamento recusado. Cada um merece um tipo. E todos são "problemas da loja", então podem ter uma mãe comum:

<?php
class LojaException extends Exception {}
class EstoqueInsuficienteException extends LojaException {}
class ProdutoNaoEncontradoException extends LojaException {}
class PagamentoRecusadoException extends LojaException {}

$catalogo = ['bolo' => 2, 'torta' => 0];

function comprar(array $catalogo, string $produto, int $qtd, bool $cartaoOk): string
{
    if (!array_key_exists($produto, $catalogo)) {
        throw new ProdutoNaoEncontradoException("Não vendemos $produto");
    }
    if ($catalogo[$produto] < $qtd) {
        throw new EstoqueInsuficienteException("Só temos {$catalogo[$produto]} $produto(s)");
    }
    if (!$cartaoOk) {
        throw new PagamentoRecusadoException('Cartão recusado');
    }
    return "Compra de $qtd $produto(s) aprovada";
}

$tentativas = [['bolo', 1, true], ['pastel', 1, true], ['torta', 1, true], ['bolo', 2, false]];
foreach ($tentativas as [$p, $q, $ok]) {
    try {
        echo comprar($catalogo, $p, $q, $ok), "\n";
    } catch (PagamentoRecusadoException $e) {
        echo "Pagamento: ", $e->getMessage(), " (tente outro cartão)\n";
    } catch (LojaException $e) {
        echo get_class($e), ": ", $e->getMessage(), "\n";
    }
}
var_dump(new EstoqueInsuficienteException() instanceof LojaException);
var_dump(new EstoqueInsuficienteException() instanceof Exception);
print_r(class_parents(new EstoqueInsuficienteException()));

A árvore fica assim:

Throwable
└── Exception
    └── LojaException
        ├── EstoqueInsuficienteException
        ├── ProdutoNaoEncontradoException
        └── PagamentoRecusadoException
$ php hierarquia.php
Compra de 1 bolo(s) aprovada
ProdutoNaoEncontradoException: Não vendemos pastel        « caiu no catch da mãe
EstoqueInsuficienteException: Só temos 0 torta(s)         « também
Pagamento: Cartão recusado (tente outro cartão)           « tratamento especial
bool(true)          « uma EstoqueInsuficienteException É uma LojaException
bool(true)          « …e também É uma Exception
Array
(
    [LojaException] => LojaException
    [Exception] => Exception
)

Com a família, quem captura escolhe o nível de detalhe:

  • catch (PagamentoRecusadoException $e): só aquele problema, com tratamento especial.
  • catch (LojaException $e): qualquer problema da loja, de uma vez. Se amanhã surgir uma CupomInvalidoException extends LojaException, esse catch já a pega, sem mudar nada.
  • Problemas que não são da loja (um TypeError, um JsonException) passam reto e não são confundidos com regras de negócio.

Estender as exceções do PHP

A sua exceção não precisa ser filha direta de Exception. Se ela é um caso particular de uma exceção que o PHP já tem (veja a tabela na aula anterior), estenda essa:

<?php
class QuantidadeInvalidaException extends InvalidArgumentException {}

function quantidade(string $texto): int
{
    if (!ctype_digit($texto)) {
        throw new QuantidadeInvalidaException("Quantidade inválida: '$texto'");
    }
    return (int) $texto;
}
try {
    quantidade('dez');
} catch (InvalidArgumentException $e) {
    echo get_class($e), " pega como InvalidArgumentException: ", $e->getMessage(), "\n";
}
$ php spl.php
QuantidadeInvalidaException pega como InvalidArgumentException: Quantidade inválida: 'dez'

Assim, um código que já captura InvalidArgumentException (de uma biblioteca, por exemplo) continua funcionando com a sua exceção.

E extends Error?

Não faça. A família Error é para erros internos do PHP (tipo errado, função inexistente). As suas exceções são da família Exception.


Organizar em arquivos

Num sistema de verdade, as exceções ficam num arquivo próprio (ou um arquivo por classe), carregado com require_once (aula include e require):

<?php
class LojaException extends Exception {}

class EstoqueInsuficienteException extends LojaException
{
    public function __construct(
        public readonly string $produto,
        public readonly int $pedido,
        public readonly int $disponivel,
    ) {
        parent::__construct("Estoque insuficiente de $produto: pediu $pedido, há $disponivel");
    }
}
<?php
require_once __DIR__ . '/excecoes.php';

try {
    throw new EstoqueInsuficienteException('pudim', 3, 1);
} catch (LojaException $e) {
    echo $e->getMessage(), "\n";
}
$ php usa-arquivo.php
Estoque insuficiente de pudim: pediu 3, há 1

No Nível 3 você vai ver o padrão profissional: cada classe no seu arquivo (EstoqueInsuficienteException.php), em namespaces, carregadas pelo autoload sem nenhum require.


Quando criar uma exceção própria?

SituaçãoFaça
Quem chama vai querer tratar esse problema de um jeito diferente dos outrosCrie uma exceção própria (PagamentoRecusadoException)
O problema tem dados úteis para quem trata (quanto faltou, qual produto)Exceção própria com construtor e propriedades
Seu sistema (ou biblioteca) tem vários problemas "de negócio"Uma mãe comum (LojaException) e filhas
Um argumento inválido qualquer, que ninguém vai tratar de forma especialUse a pronta: InvalidArgumentException
"Não encontrei" é um resultado normal (buscar um cliente que pode não existir)Nem exceção: devolva null
Uma exceção para cada linha do programaNão: exceções demais viram ruído. Crie quando o tipo fizer diferença para quem captura.

Quando dá errado

"Cannot throw objects that do not implement Throwable" (esqueceu o extends)
<?php
class EstoqueInsuficienteException
{
}
throw new EstoqueInsuficienteException();
$ php sem-extends.php

Fatal error: Uncaught Error: Cannot throw objects that do not implement Throwable in /home/ana/excp/sem-extends.php:5
Stack trace:
#0 {main}
  thrown in /home/ana/excp/sem-extends.php on line 5

O que significa: o nome termina em Exception, mas a classe é comum; só objetos da família Throwable podem ser lançados.

Como consertar: class EstoqueInsuficienteException extends Exception.

"cannot implement interface Throwable, extend Exception or Error instead"
$ php implements.php      « class EstoqueInsuficienteException implements Throwable

Fatal error: Class EstoqueInsuficienteException cannot implement interface Throwable, extend Exception or Error instead in /home/ana/excp/implements.php on line 2
Stack trace:
#0 {main}

Como consertar: a própria mensagem diz: estenda Exception.

A mensagem saiu vazia (esqueceu o parent::__construct)
<?php
class EstoqueInsuficienteException extends Exception
{
    public function __construct(public readonly string $produto)
    {
    }
}
try {
    throw new EstoqueInsuficienteException('bolo');
} catch (EstoqueInsuficienteException $e) {
    var_dump($e->getMessage());
}
$ php sem-parent.php
string(0) ""

O que significa: o construtor próprio substituiu o da Exception, e ninguém entregou a mensagem a ela.

Como consertar: chame parent::__construct("…mensagem…"); dentro do seu construtor.

"Too few arguments to function …::__construct()"
$ php argumentos.php      « o construtor pede (produto, disponível) e alguém passou só um texto

Fatal error: Uncaught ArgumentCountError: Too few arguments to function EstoqueInsuficienteException::__construct(), 1 passed in /home/ana/excp/argumentos.php on line 9 and exactly 2 expected in /home/ana/excp/argumentos.php:4
Stack trace:
#0 /home/ana/excp/argumentos.php(9): EstoqueInsuficienteException->__construct('Estoque acabou')
#1 {main}
  thrown in /home/ana/excp/argumentos.php on line 4

O que significa: depois que a exceção ganhou um construtor próprio, o new precisa passar os argumentos dele, e não mais uma mensagem.

Como consertar: new EstoqueInsuficienteException('bolo', 0), na ordem do construtor (ou com argumentos nomeados).

"Cannot override final method Exception::getMessage()"
$ php final.php      « tentou criar um método getMessage() na exceção

Fatal error: Cannot override final method Exception::getMessage() in /home/ana/excp/final.php on line 4
Stack trace:
#0 {main}

O que significa: os métodos get… da Exception são final ("finais"): não podem ser substituídos.

Como consertar: passe a mensagem pelo parent::__construct. Para informações extras, crie propriedades ou métodos com outros nomes.


Referência

SintaxeO que faz
class XException extends Exception {}Cria uma exceção própria.
class XException extends LojaException {}Filha de outra exceção sua (hierarquia).
class XException extends InvalidArgumentException {}Caso particular de uma exceção do PHP.
public function __construct(public readonly int $x) { … }Construtor que guarda dados na exceção.
parent::__construct($mensagem, $codigo, $anterior)Entrega mensagem, código e causa à Exception.
$e instanceof LojaExceptionPergunta se a exceção é daquela família.
get_class($e), class_parents($e)Classe exata e "antepassados".

Exercícios

1. A mãe certa

Complete para criar uma exceção própria:

class CupomInvalidoException {}

2. Entregar a mensagem

Complete a linha dentro do construtor da exceção:

("Cupom $codigo expirou");

3. Pegar a família toda

Todas as exceções da loja estendem LojaException. Complete para capturar qualquer uma:

} catch ( $e) {

4. class PagamentoRecusadoException extends LojaException {} e class LojaException extends Exception {}. Qual catch não captura uma PagamentoRecusadoException?

5. Por que criar EstoqueInsuficienteException em vez de lançar new Exception("Estoque insuficiente")?

6. A exceção tem construtor próprio, mas getMessage() devolve "". O que falta?

Missão final: no arquivo excecoes.php, crie LojaException (filha de Exception) e duas filhas dela, CupomInexistenteException e CupomExpiradoException, cada uma com um construtor que recebe o código do cupom e monta a mensagem (O cupom XYZ não existe, O cupom NATAL25 expirou). No index.php, lance a exceção certa na função e complete o catch para mostrar Cupom recusado (Classe): mensagem para qualquer exceção da loja. O último teste provoca um TypeError, que já tem o seu próprio catch.

<?php
require_once __DIR__ . '/excecoes.php';

$cupons = ['PROMO10' => ['desconto' => 10, 'valido' => true],
           'NATAL25' => ['desconto' => 25, 'valido' => false]];

function aplicar(array $cupons, string $codigo, float $total): float
{
    // lance CupomInexistenteException ou CupomExpiradoException aqui
    return $total * (100 - $cupons[$codigo]['desconto']) / 100;
}

foreach (['PROMO10', 'NATAL25', 'XYZ', null] as $codigo) {
    try {
        $final = aplicar($cupons, $codigo, 100);
        echo "Desconto de {$cupons[$codigo]['desconto']}%: R$ " . number_format($final, 2, ',', '.') . "\n";
    } catch (TypeError $e) {
        echo "Erro inesperado (" . get_class($e) . ")\n";
    }
}
<?php
// crie as exceções aqui
<?php
require_once __DIR__ . '/excecoes.php';

$cupons = ['PROMO10' => ['desconto' => 10, 'valido' => true],
           'NATAL25' => ['desconto' => 25, 'valido' => false]];

function aplicar(array $cupons, string $codigo, float $total): float
{
    if (!isset($cupons[$codigo])) {
        throw new CupomInexistenteException($codigo);
    }
    if (!$cupons[$codigo]['valido']) {
        throw new CupomExpiradoException($codigo);
    }
    return $total * (100 - $cupons[$codigo]['desconto']) / 100;
}

foreach (['PROMO10', 'NATAL25', 'XYZ', null] as $codigo) {
    try {
        $final = aplicar($cupons, $codigo, 100);
        echo "Desconto de {$cupons[$codigo]['desconto']}%: R$ " . number_format($final, 2, ',', '.') . "\n";
    } catch (LojaException $e) {
        echo "Cupom recusado (" . get_class($e) . "): " . $e->getMessage() . "\n";
    } catch (TypeError $e) {
        echo "Erro inesperado (" . get_class($e) . ")\n";
    }
}
<?php
class LojaException extends Exception {}

class CupomInexistenteException extends LojaException
{
    public function __construct(public readonly string $codigo)
    {
        parent::__construct("O cupom $codigo não existe");
    }
}

class CupomExpiradoException extends LojaException
{
    public function __construct(public readonly string $codigo)
    {
        parent::__construct("O cupom $codigo expirou");
    }
}

Resumo

  • Uma exceção própria é uma classe que estende Exception: class EstoqueInsuficienteException extends Exception {}.
  • O nome diz o problema e termina em Exception; quem captura pode tratá-la separadamente.
  • Um construtor guarda dados (public readonly) e monta a mensagem, entregue com parent::__construct(…).
  • Uma mãe comum (LojaException) permite capturar a família toda; filhas permitem tratar casos específicos.
  • Casos particulares de exceções do PHP podem estendê-las (extends InvalidArgumentException); nunca estenda Error.
  • Crie exceções quando o tipo fizer diferença para quem captura.

Próxima aula: Depurar, para encontrar o problema quando a mensagem de erro não basta.