Exceptions além dos erros: usando throw e try/catch para controlar o fluxo
16 min de leitura #boas-praticas #laravel
Quando pensamos em exceptions, normalmente pensamos em algo que deu errado: uma API caiu, um registro não existe, uma operação falhou.
Mas exceptions podem ter um papel mais interessante do que simplesmente representar erros.
Quando existe um padrão consistente para criá-las, lançá-las e tratá-las, throw e try/catch também podem ser usados como mecanismos de controle de fluxo.
A ideia não é sair colocando try/catch em todo lugar. É justamente o contrário: deixar a exception subir até o ponto em que alguém realmente tenha uma decisão a tomar.
Neste artigo, vou mostrar como esse padrão funciona e alguns casos em que ele torna o código mais simples.
O problema de retornar null e esperar que alguém trate
Imagine uma operação para criar um pedido:
$customer = $this->customers->find($id);
if ($customer === null) {
return response()->json(['erro' => 'Cliente não encontrado'], 404);
}
if ($customer->isBlocked()) {
return response()->json(['erro' => 'Cliente bloqueado'], 403);
}
if (! $customer->hasCredit($valor)) {
return response()->json(['erro' => 'Sem crédito'], 422);
}
return $this->createOrder($customer, $valor);
Funciona, mas existem alguns problemas.
O primeiro é que o caminho feliz desaparece. A regra de negócio que realmente queremos executar está escondida no meio de várias verificações.
O segundo é que alguém pode esquecer uma dessas verificações. Se find() retornar null e o código não tratar esse caso, o problema só aparecerá mais tarde.
E o terceiro é que a camada responsável pela regra de negócio começa a conhecer detalhes de HTTP:
return response()->json(..., 403);
Isso faz com que cada controller passe a decidir como um erro deve ser apresentado.
Uma alternativa é fazer a operação falhar explicitamente:
$customer = $this->customers->findOrFail($id);
$customer->assertNotBlocked();
$customer->assertHasCredit($valor);
return $this->createOrder($customer, $valor);
Agora o código descreve principalmente o caminho feliz.
Se alguma coisa não puder ser executada, a operação lança uma exception e interrompe o fluxo naquele ponto.
Essa exception pode subir pela pilha até chegar a uma camada responsável por decidir o que fazer com ela.
A regra mental é simples:
Se o método não consegue fazer o que o nome dele promete, ele lança. Ele não devolve um "meio sucesso".
Exceptions como parte do contrato
Para esse modelo funcionar bem, é importante que as exceptions tenham significado.
Uma exception genérica como:
throw new Exception('Erro');
não diz muita coisa.
Já:
throw new InsufficientBalanceException();
carrega uma informação que outras partes da aplicação podem entender.
Em um projeto, podemos estabelecer uma hierarquia simples para separar dois grandes grupos de problemas:
- erros que fazem parte do contrato HTTP;
- erros internos do processo.
Quando a exception faz parte do contrato HTTP
Alguns erros são esperados e fazem parte do comportamento normal da API.
Por exemplo:
- cliente bloqueado;
- recurso não encontrado;
- permissão negada;
- saldo insuficiente;
- pedido já pago.
Esses erros podem ser representados por uma HttpException:
final class InsufficientBalanceException extends HttpException
{
public function errorCode(): string
{
return 'INSUFFICIENT_BALANCE';
}
protected function status(): int
{
return 422;
}
protected function defaultMessage(): string
{
return 'Saldo insuficiente para concluir a operação.';
}
}
E o código que precisa sinalizar o problema simplesmente faz:
throw new InsufficientBalanceException();
A própria exception sabe qual é o código e qual status HTTP deve ser utilizado.
O errorCode() é particularmente importante quando existe um frontend consumindo a API.
A mensagem:
{
"message": "Saldo insuficiente para concluir a operação."
}
é destinada principalmente a humanos.
Já:
{
"error_code": "INSUFFICIENT_BALANCE"
}
é um contrato mais estável para o software.
A mensagem pode mudar sem que o frontend precise mudar sua lógica.
E quando o problema é interno?
O segundo tipo é a RuntimeException.
Aqui a ideia é bem diferente.
Uma RuntimeException representa uma falha que aconteceu durante um processo que não tem como destino uma resposta HTTP.
Pense em:
- um job processando uma fila;
- um comando Artisan;
- uma sincronização em background;
- um processamento em lote;
- uma integração executada de forma assíncrona.
Nesses casos, não existe um usuário esperando um 422 ou 404.
Se alguma coisa realmente inesperada acontecer, queremos que o processo pare.
Por exemplo:
final class InvoiceSyncFailedException extends RuntimeException
{
public function errorCode(): string
{
return 'INVOICE_SYNC_FAILED';
}
}
E durante o processamento:
public function handle(): void
{
$invoice = $this->invoice->refresh();
if (! $invoice->canBeSynchronized()) {
throw new InvoiceSyncFailedException(
"Não foi possível sincronizar a nota {$invoice->id}."
);
}
$this->sync($invoice);
}
A intenção aqui não é capturar a exception e continuar como se nada tivesse acontecido.
É justamente o contrário:
Se um processo em background chegou a uma situação que ele não sabe resolver, ele deve falhar.
Isso é particularmente importante quando estamos trabalhando com filas.
Se um job lança uma RuntimeException e não existe um catch capaz de resolvê-la, o Laravel considera que o job falhou. Dependendo da configuração da fila, ele pode ser executado novamente pelos mecanismos de retry e, depois de atingir o limite de tentativas, ser enviado para failed_jobs.
Ou seja, a exception não é apenas uma mensagem de erro.
Ela comunica para o sistema de execução:
"Este processo não conseguiu terminar."
E isso é muito diferente de capturar tudo:
try {
$this->sync($invoice);
} catch (RuntimeException $e) {
Log::error($e->getMessage());
}
Nesse caso, você acabou de transformar uma falha real em um processo que parece ter terminado com sucesso.
Para um job, isso pode ser ainda pior: a fila pode entender que o trabalho terminou normalmente, mesmo que nada tenha sido processado corretamente.
Por isso, uma RuntimeException deve ser tratada como uma exception de verdade: algo que interrompe o processo e precisa chegar até quem é responsável por lidar com a falha.
E o que acontece se uma RuntimeException aparecer em uma request?
A separação continua importante.
Uma RuntimeException não foi criada para representar um erro que o cliente da API precisa tratar.
Se ela escapar de um processo HTTP, isso significa que alguma coisa inesperada aconteceu durante aquela request.
Nesse cenário, ela não deve ser convertida artificialmente em um erro de negócio como:
{
"message": "Algo deu errado.",
"error_code": "SOMETHING_WENT_WRONG"
}
O comportamento esperado é deixar o Laravel tratá-la como uma falha interna e responder com 500.
Isso também ajuda a manter uma distinção importante:
flowchart TD
A[HttpException] --> B["Essa situação faz parte<br/>do contrato da API."]
B --> C["Resposta HTTP conhecida"]
D[RuntimeException] --> E["Esse processo<br/>não conseguiu continuar."]
E --> F["Falha do processo"]
Uma diferença importante no Laravel
O Laravel já possui suporte nativo para HttpException: quando uma exception desse tipo é lançada, o framework a trata como uma exception HTTP e a transforma em uma resposta HTTP.
No nosso padrão, aproveitamos esse comportamento para adicionar algumas convenções próprias. A exception carrega o status HTTP e o error_code, enquanto o handler global fica responsável por padronizar o corpo da resposta.
Por exemplo:
throw new CustomerBlockedHttpException();
A aplicação pode transformar isso em uma resposta como:
{
"message": "Cliente bloqueado.",
"error_code": "CUSTOMER_BLOCKED"
}
com o status HTTP definido pela própria exception.
O ponto importante é que o controller não precisa conhecer essa transformação. Ele apenas lança a exception:
throw new CustomerBlockedHttpException();
O Laravel já sabe lidar com uma HttpException como uma resposta HTTP. Nosso código apenas estabelece uma convenção sobre como essas exceptions representam os erros da aplicação.
Já a RuntimeException tem uma intenção diferente.
Ela não representa uma resposta HTTP de negócio. Ela representa uma falha do processo que não conseguiu continuar.
Se estivermos executando um job, por exemplo:
public function handle(): void
{
$this->syncInvoice();
}
e alguma RuntimeException escapar:
throw new InvoiceSyncFailedException();
o job falha. A infraestrutura da fila pode então aplicar a política de retry configurada e, caso o número máximo de tentativas seja atingido, registrar o job em failed_jobs.
Nesse caso, a exception está comunicando algo diferente:
"Este processo não conseguiu terminar."
Essa distinção ajuda a decidir qual tipo de exception utilizar:
flowchart TD
A[HttpException] --> B["Essa situação faz parte<br/>do contrato da API"]
B --> C["Laravel transforma<br/>em resposta HTTP"]
D[RuntimeException] --> E["Esse processo não<br/>conseguiu continuar"]
E --> F["Processo falha /<br/>job pode sofrer retry"]
A pergunta para decidir entre as duas fica simples:
Essa exception representa uma situação que o consumidor da API deve receber como parte do contrato HTTP?
Se sim, HttpException.
Se não, e estamos diante de uma falha que deve interromper o processo, RuntimeException.
| Situação | Base | Comportamento |
|---|---|---|
| Cliente bloqueado tentando comprar | HttpException |
Resposta HTTP conhecida |
| CPF não encontrado em uma API | HttpException |
Resposta HTTP conhecida |
| Pedido abaixo do mínimo | HttpException |
Resposta HTTP conhecida |
| Job não conseguiu sincronizar uma nota | RuntimeException |
Job falha / retry |
| Integração em background falhou de forma inesperada | RuntimeException |
Processo falha |
| Invariante interna foi violada | RuntimeException |
Processo é interrompido |
| Bug inesperado durante um job | RuntimeException / erro não tratado |
Processo falha |
| Falha interna durante uma request | RuntimeException |
HTTP 500 |
A diferença fundamental é que HttpException comunica uma condição esperada ao consumidor, enquanto RuntimeException comunica que o processo não conseguiu continuar.
O handler global
Uma das consequências mais interessantes desse modelo é que o controller não precisa saber como formatar cada erro.
O fluxo pode ser:
Controller / Action / Job
│
│ throw
▼
Exception
│
│ sobe pela pilha
▼
Handler global
│
├── HttpException
│ └── JSON + status
│
└── RuntimeException
└── erro interno / 500
No Laravel, essa integração pode ser feita no ciclo de vida da aplicação.
O código que executa a regra de negócio não precisa saber como a exception será apresentada ao usuário.
Isso cria uma separação importante:
A regra de negócio decide que algo falhou. A camada de infraestrutura decide como essa falha será apresentada.
Assim, não precisamos espalhar isso pelo projeto:
return response()->json([
'message' => 'Cliente bloqueado',
'error_code' => 'CUSTOMER_BLOCKED',
], 403);
Cada vez que uma regra falha, basta lançar a exception adequada:
throw new CustomerBlockedHttpException();
E onde entra o try/catch?
Aqui está a parte mais importante.
É comum encontrar código assim:
try {
$this->processPayment($order);
} catch (Exception $e) {
Log::error($e->getMessage());
return response()->json([
'erro' => 'algo deu errado'
], 500);
}
Parece prudente.
Mas, nesse cenário, o catch não está realmente resolvendo nada.
Ele apenas:
- captura a exception;
- escreve um log;
- cria uma resposta HTTP genérica.
Se o handler global já faz isso, esse catch só adicionou complexidade.
Prefira:
$this->processPayment($order);
Se processPayment() lançar uma exception, ela sobe até o handler.
A regra que gosto de usar é:
Só escreva um
catchquando você tiver uma ação concreta para tomar naquele ponto.
Logar não é necessariamente uma ação local. Se o handler global já registra o erro, não há motivo para fazer isso novamente.
Uma ação concreta pode ser:
- tentar outro provedor;
- transformar uma exception;
- compensar uma operação;
- continuar um processamento;
- reagendar um job;
- liberar um recurso.
É aí que try/catch começa a ficar interessante.
Caso 1: usar catch para fazer fallback
Imagine que temos dois provedores para consultar uma cotação.
O primeiro é o principal:
public function getExchangeRate(string $currency): float
{
try {
return $this->primaryProvider->rate($currency);
} catch (ProviderUnavailableException $e) {
Log::warning('Provedor primário fora do ar, usando fallback.', [
'error_code' => $e->errorCode(),
]);
return $this->fallbackProvider->rate($currency);
}
}
Aqui existe uma razão concreta para o catch.
O código sabe o que fazer quando o provedor principal falha:
usar outro provedor.
Também é importante capturar a exception específica:
catch (ProviderUnavailableException $e)
e não:
catch (Exception $e)
Se o provedor lançar um TypeError por causa de um bug no nosso código, por exemplo, não queremos interpretar isso como indisponibilidade do provedor.
O bug deve continuar subindo.
Caso 2: traduzir uma exception
Outro uso muito útil é quando uma biblioteca ou serviço externo possui uma linguagem que não faz sentido para o nosso domínio.
Imagine um gateway de pagamento:
public function charge(
Customer $customer,
Money $amount
): Charge {
try {
return $this->gateway->charge(
$customer->gateway_id,
$amount->cents()
);
} catch (GatewayCardDeclinedError $e) {
throw new PaymentDeclinedException(
message: "Pagamento recusado para o cliente {$customer->id}.",
previous: $e,
);
}
}
O gateway conhece:
GatewayCardDeclinedError
Mas o nosso domínio conhece:
PaymentDeclinedException
Essa tradução é importante porque evita espalhar conceitos específicos do fornecedor pela aplicação.
E existe outro detalhe fundamental:
previous: $e
Ao traduzir uma exception, devemos preservar a original.
Assim:
PaymentDeclinedException
│
└── previous
│
└── GatewayCardDeclinedError
No log, continuamos tendo acesso à causa original.
Sem isso:
throw new PaymentDeclinedException();
podemos perder justamente a informação necessária para descobrir o que aconteceu no serviço externo.
Caso 3: continuar um processamento em lote
Imagine um processo que precisa sincronizar 5.000 notas fiscais.
Se a terceira nota tiver um problema previsto pelo domínio, talvez não faça sentido interromper todo o processamento.
Podemos fazer:
public function syncAllInvoices(
Collection $invoices
): SyncReport {
$report = new SyncReport();
foreach ($invoices as $invoice) {
try {
$this->syncOne($invoice);
$report->addSuccess($invoice->id);
} catch (DomainException $e) {
$report->addFailure(
$invoice->id,
$e->errorCode()
);
}
}
return $report;
}
Aqui o catch possui uma responsabilidade clara:
registrar a falha e continuar para o próximo item.
E isso fica ainda melhor quando existe um contrato comum para as exceptions de domínio.
Em vez de fazer:
catch (CustomerBlockedException | InvoiceInvalidException | ...)
podemos capturar:
catch (DomainException $e)
Assim, qualquer erro previsto pelo domínio pode ser tratado da mesma maneira.
Por outro lado, um TypeError ou uma falha de conexão com o banco continua subindo.
Isso é importante.
Não queremos que o mecanismo de tratamento de erros transforme qualquer bug em uma falha recuperável.
Caso 4: múltiplos catch
O PHP avalia os catch de cima para baixo.
Por isso, quando existem vários tipos possíveis, devemos começar pelo mais específico:
try {
$this->shipOrder($order);
} catch (AddressNotFoundException $e) {
return $this->requestNewAddress($order);
} catch (
CarrierUnavailableException |
CarrierTimeoutException $e
) {
ShipOrderJob::dispatch($order)
->delay(now()->addMinutes(30));
return $this->acceptedResponse($order);
} catch (DomainException $e) {
$order->markShippingFailed($e->errorCode());
throw $e;
}
O último catch captura qualquer outro erro de domínio.
E depois:
throw $e;
Isso também é perfeitamente válido.
Capturar uma exception não significa necessariamente que você precisa encerrá-la.
Você pode:
- capturar;
- tomar uma ação local;
- relançar;
- deixar outra camada continuar o tratamento.
Nesse exemplo, o pedido é marcado como falho, mas a responsabilidade de gerar a resposta HTTP continua sendo do handler global.
finally: quando você não quer tratar o erro
Embora eu não utilize muito essa prática, existe ainda uma situação em que queremos executar alguma coisa independentemente do resultado.
Nesse caso, podemos usar finally.
Por exemplo:
public function importFile(UploadedFile $file): ImportResult
{
$handle = fopen($file->path(), 'r');
try {
return $this->parseAndImport($handle);
} finally {
fclose($handle);
}
}
O finally será executado:
- quando tudo der certo;
- quando uma exception for lançada;
- quando houver um
return.
E existe um detalhe interessante:
Você pode ter
trysemcatch.
Não queremos tratar a exception.
Queremos apenas garantir que o recurso seja liberado antes que ela continue subindo.
Esse padrão também pode ser útil para:
- arquivos;
- locks;
- conexões;
- estados temporários;
- recursos externos.
Exceptions e transações
Um dos exemplos mais interessantes de controle de fluxo acontece dentro de uma transação.
No Laravel:
DB::transaction(function () use ($cart) {
$order = Order::create([...]);
foreach ($cart->items as $item) {
$stock = Stock::lockForUpdate()
->find($item->product_id);
if ($stock->quantity < $item->quantity) {
throw new InsufficientStockException(
"Estoque insuficiente para o produto {$item->product_id}."
);
}
$stock->decrement(
'quantity',
$item->quantity
);
$order->items()->create([...]);
}
return $order;
});
Se o terceiro item não tiver estoque, fazemos apenas:
throw new InsufficientStockException();
A transação percebe a exception e faz o rollback.
Não precisamos escrever:
DB::rollBack();
nem precisamos capturar a exception ali.
O throw interrompe a execução, a transação desfaz as alterações e a exception continua subindo.
Uma única linha acaba participando de três comportamentos:
- interrompe o fluxo;
- provoca o rollback;
- comunica o erro para a camada superior.
Esse é um dos melhores exemplos de como exceptions podem funcionar como mecanismo de composição entre diferentes partes da aplicação.
Guard clauses que lançam exceptions
Outra aplicação interessante é colocar invariantes próximas da entidade que as conhece.
Por exemplo:
final class Customer extends Model
{
public function assertCanPurchase(): void
{
if ($this->isBlocked()) {
throw new CustomerBlockedHttpException(
"Cliente {$this->id} está bloqueado."
);
}
if (! $this->hasVerifiedDocuments()) {
throw new CustomerDocumentsPendingException();
}
}
}
Agora qualquer lugar que precise dessa garantia pode simplesmente fazer:
$customer->assertCanPurchase();
O código que chama não precisa conhecer todas as condições.
Se amanhã surgir uma nova regra:
if (! $this->hasVerifiedDocuments()) {
throw new CustomerDocumentsPendingException();
}
if (! $this->hasCredit()) {
throw new InsufficientCreditException();
}
os lugares que chamam:
$customer->assertCanPurchase();
não precisam ser alterados.
A regra está centralizada.
Por isso, gosto de convenções que deixam explícito no nome que um método pode interromper o fluxo:
assertCanPurchase()
ensurePaid()
findOrFail()
O próprio nome funciona como documentação.
Quando não usar exceptions
Tudo isso pode parecer um argumento para substituir qualquer if por throw.
Não é.
Exceptions não devem ser usadas para representar todo caminho alternativo da aplicação.
Imagine:
$user = User::find($id);
Se não encontrar o usuário é uma situação perfeitamente normal, talvez não faça sentido:
try {
$user = User::findOrFail($id);
} catch (ModelNotFoundException) {
$user = User::guest();
}
Nesse caso, o caminho alternativo faz parte do comportamento esperado.
É mais simples:
$user = User::find($id) ?? User::guest();
A diferença está na semântica.
Uma exception representa:
Essa operação não conseguiu cumprir o contrato que prometeu.
Um retorno normal representa:
Essa é uma possibilidade esperada dessa operação.
Essa distinção é mais importante do que simplesmente decidir entre if e try/catch.
Conclusão
Exceptions não precisam ser apenas uma forma de dizer que alguma coisa deu errado. Quando existe uma estrutura clara, elas também podem ajudar a organizar o fluxo da aplicação e separar responsabilidades.
A ideia não é substituir todo if por um throw, nem colocar try/catch em todo lugar. É saber quando uma operação deve falhar, quem tem responsabilidade para tratar essa falha e quando ela simplesmente deve continuar subindo.
No fim, um bom uso de exceptions deixa o caminho feliz mais evidente e faz com que cada camada lide apenas com aquilo que realmente conhece.