Salvou o pedido, mas não salvou os itens. E agora?
Imagina a cena: seu checkout cria o pedido, dá baixa no estoque e registra o pagamento. Três operações no banco, uma atrás da outra.
Aí no meio do caminho o banco cai. Ou uma validação estoura. Ou a API de pagamento dá timeout.
Resultado? O pedido foi criado, o estoque foi debitado, mas o pagamento nunca registrou. Você acabou de vender um produto de graça. 🎉
Esse é o pesadelo do dado inconsistente. E a solução tem nome: transaction.
O código que parece inofensivo (mas não é)
Olha esse controller bem comum:
public function store(Request $request)
{
$pedido = Pedido::create($request->only('cliente_id', 'total'));
foreach ($request->itens as $item) {
$pedido->itens()->create($item);
Produto::find($item['produto_id'])->decrement('estoque', $item['qtd']);
}
Pagamento::create([
'pedido_id' => $pedido->id,
'valor' => $pedido->total,
]);
return response()->json($pedido);
}
Parece limpo, né? O problema é que cada linha grava no banco na hora.
Se der ruim na criação do pagamento, o pedido e os itens já estão salvos. Não tem volta. Seu banco fica com um Frankenstein: metade de uma operação que era pra ser uma coisa só.
O conceito por trás: atomicidade
Uma transaction resolve isso com uma ideia simples: tudo ou nada.
Ou todas as operações dão certo e são gravadas de vez (o commit), ou qualquer uma falha e o banco desfaz tudo como se nada tivesse acontecido (o rollback).
É o mesmo princípio de uma transferência bancária: não existe "saiu da minha conta mas não entrou na sua". Ou acontece por inteiro, ou não acontece.
A solução: DB::transaction()
O Laravel deixa isso ridiculamente fácil. Você embrulha o código num DB::transaction() e pronto:
use Illuminate\Support\Facades\DB;
public function store(Request $request)
{
$pedido = DB::transaction(function () use ($request) {
$pedido = Pedido::create($request->only('cliente_id', 'total'));
foreach ($request->itens as $item) {
$pedido->itens()->create($item);
Produto::find($item['produto_id'])->decrement('estoque', $item['qtd']);
}
Pagamento::create([
'pedido_id' => $pedido->id,
'valor' => $pedido->total,
]);
return $pedido;
});
return response()->json($pedido);
}
Só isso. Se qualquer linha dentro da closure lançar uma exception, o Laravel faz o rollback automático de tudo. Nada é gravado.
Se rodar até o fim sem erro, ele dá o commit. E de bônus: o que a closure retornar (return $pedido) vira o retorno do DB::transaction(). Prático demais.
Repara que não precisa de try/catch, nem de commit, nem de rollBack na mão. A closure faz o trabalho sujo pra você.
Como usar na prática
No checkout que a gente viu: garante que pedido, itens, estoque e pagamento vivem e morrem juntos. Nunca mais um pedido órfão.
Num cadastro com múltiplas tabelas: criar o usuário, o perfil e as configurações padrão. Se o perfil falhar, o usuário não fica solto no banco sem perfil.
Numa importação em lote: processar 500 linhas de uma planilha. Se a linha 480 tiver um erro, você não quer 479 registros pela metade. Ou entra tudo, ou você corrige a planilha e roda de novo.
A versão manual (pra quando você precisa de controle)
Às vezes a closure não dá conta — você precisa decidir quando fazer commit ou rollback com base em alguma lógica. Aí usa a forma manual:
DB::beginTransaction();
try {
$pedido = Pedido::create($dados);
// ... mais operações ...
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
report($e); // loga o erro pra você investigar depois
throw $e; // relança pra não engolir o problema em silêncio
}
Funciona igual, só que você está no controle. Mas repara: se você usar essa versão, não engula a exception. Fazer o rollback e seguir a vida como se nada tivesse acontecido esconde o bug. Loga e relança.
Na dúvida, prefira o DB::transaction(). Menos código, menos chance de esquecer um rollBack.
A pegadinha que pega todo mundo
Transaction serve pra banco de dados. Só isso.
O erro clássico é enfiar coisas que não são banco dentro dela, achando que vão ser "desfeitas" também:
DB::transaction(function () use ($pedido) {
Pedido::create($pedido);
Mail::to($cliente)->send(new PedidoConfirmado); // ⚠️ perigo
Storage::put('nota.pdf', $conteudo); // ⚠️ perigo
});
Se a transaction der rollback depois disso, o pedido some do banco — mas o e-mail já foi enviado e o arquivo já foi salvo. Rollback não volta no tempo pro mundo real.
A regra de ouro: dentro da transaction, só banco. E-mail, envio de arquivo, chamada de API, disparo de job — deixa pra depois do commit.
O Laravel até te ajuda nisso com DB::afterCommit() (e jobs que implementam ShouldQueue já respeitam isso automaticamente se você configurar after_commit). Assim o e-mail só dispara quando o commit deu certo de verdade.
Bônus: e agora?
Se você trabalha com Service Layer, a transaction geralmente mora lá dentro do método do service — não no controller. O controller só orquestra, o service garante a consistência.
E vale abrir a doc pra dar uma olhada em retry (o segundo parâmetro do DB::transaction(), útil pra deadlocks) e em transactions aninhadas com savepoints. Assunto pra um próximo post. 😉
Antes de você fechar a aba
Se você tem algum fluxo que grava em várias tabelas seguidas e ainda não usa transaction, esse é o sinal pra revisar. É o tipo de bug que não aparece nos testes felizes e só dá as caras em produção, numa sexta à noite.
Você já tomou um susto com dado pela metade no banco? Conta aí nos comentários — aposto que tem história boa.
E se esse post te salvou de um checkout quebrado, compartilha com aquele colega que ainda faz três create() seguidos sem rede de proteção. 🙂
Top comments (0)