Soft Deletes no Laravel: apague sem apagar de verdade
Soft Deletes in Laravel: Delete without actually deleting
“Some do banco” nem sempre é o que você quer. Imagina a cena: o cliente liga desesperado porque um funcionário apagou um pedido importante. Você abre o banco, e… não tem. Sumiu. DELETE é DELETE. Agora é backup ou choro. “Disappearing from the database” isn’t always what you want. Imagine the scene: a client calls in a panic because an employee deleted an important order. You open the database, and… it’s gone. It vanished. DELETE is DELETE. Now it’s either a backup or tears.
Agora imagina a mesma cena, só que você digita $pedido->restore() e o registro volta como se nada tivesse acontecido. Herói do dia. Essa é a proposta do Soft Delete: em vez de apagar a linha de verdade, o Laravel marca ela como apagada e finge que ela não existe mais nas consultas. Ela continua lá, só invisível. E dá pra trazer de volta quando quiser.
Now imagine the same scene, but you type $pedido->restore() and the record returns as if nothing happened. Hero of the day. That is the proposal of Soft Delete: instead of actually deleting the row, Laravel marks it as deleted and pretends it no longer exists in queries. It’s still there, just invisible. And you can bring it back whenever you want.
O problema: delete() é definitivo
The problem: delete() is definitive
Sem soft delete, isso aqui é irreversível: $pedido->delete(); // tchau. pra sempre. 💀 Some do banco, some dos relatórios, some da vida. Se alguém apagou por engano, ou se você precisa de histórico pra auditoria, azar. E é impressionante como “apagou por engano” acontece com frequência em produção.
Without soft delete, this is irreversible: $pedido->delete(); // bye. forever. 💀 It disappears from the database, from reports, from life. If someone deleted it by mistake, or if you need history for an audit, you’re out of luck. And it’s impressive how often “deleted by mistake” happens in production.
A solução: três passos e pronto
The solution: three steps and you’re done
Primeiro, adiciona a coluna deleted_at na migration. O Laravel tem um helper só pra isso:
First, add the deleted_at column to the migration. Laravel has a helper just for this:
Schema::table('pedidos', function (Blueprint $table) {
$table->softDeletes(); // cria a coluna deleted_at (nullable)
});
Depois, usa a trait SoftDeletes na model:
Then, use the SoftDeletes trait in the model:
use Illuminate\Database\Eloquent\SoftDeletes;
class Pedido extends Model {
use SoftDeletes;
}
Pronto. Agora o delete() mudou de comportamento sem você tocar em mais nada: $pedido->delete(); // não apaga a linha — só preenche deleted_at com a data/hora de agora. E o melhor: as consultas normais ignoram automaticamente os registros apagados. Pedido::all() não traz nada que foi soft-deletado. Você não precisa filtrar nada na mão.
Done. Now delete() has changed its behavior without you touching anything else: $pedido->delete(); // doesn't delete the row — just fills deleted_at with the current timestamp. And the best part: normal queries automatically ignore deleted records. Pedido::all() won’t return anything that was soft-deleted. You don’t need to filter anything manually.
Trazendo de volta e vendo os apagados
Bringing them back and viewing deleted records
Restaurar é uma linha: $pedido->restore(); // deleted_at volta pra null, registro "revive".
Restoring is one line: $pedido->restore(); // deleted_at goes back to null, the record "revives".
Pra incluir os apagados numa consulta, withTrashed: Pedido::withTrashed()->get(); // traz tudo, apagados junto.
To include deleted records in a query, use withTrashed: Pedido::withTrashed()->get(); // brings everything, including deleted ones.
Pra ver só os apagados (útil pra uma tela de “lixeira”): Pedido::onlyTrashed()->get();.
To see only the deleted ones (useful for a “trash” screen): Pedido::onlyTrashed()->get();.
E se precisar apagar de verdade, aí sim, sem volta: $pedido->forceDelete(); // agora foi pro além de verdade.
And if you need to delete it for real, with no turning back: $pedido->forceDelete(); // now it's truly gone.
Como usar na prática
How to use it in practice
- Lixeira pro usuário: Uma tela onde ele apaga itens, mas tem 30 dias pra restaurar.
onlyTrashed()lista,restore()recupera. User Trash: A screen where they delete items but have 30 days to restore them.onlyTrashed()lists them,restore()recovers them. - Auditoria e histórico: Setores como financeiro e jurídico costumam exigir que nada suma de verdade. Soft delete mantém o rastro sem poluir as telas do dia a dia. Audit and history: Sectors like finance and legal often require that nothing truly disappears. Soft delete keeps the trail without cluttering daily screens.
- Checar o estado no código: Dá pra saber se um registro tá apagado:
if ($pedido->trashed()) { // esse aqui tá na lixeira }. Check status in code: You can tell if a record is deleted:if ($pedido->trashed()) { // this one is in the trash }.
Pegadinha 1: unique não sabe de soft delete
Gotcha 1: unique doesn’t know about soft delete
Essa pega muita gente. Você tem uma coluna email com índice unique. O usuário joao@email.com é soft-deletado. Ele tenta se cadastrar de novo… e o banco recusa, porque a linha antiga ainda está lá, ocupando o email. O unique do banco não liga pro deleted_at — pra ele, a linha existe.
This catches many people. You have an email column with a unique index. User joao@email.com is soft-deleted. They try to sign up again… and the database refuses, because the old row is still there, occupying the email. The database unique doesn’t care about deleted_at — to it, the row exists.
Na validação, você precisa dizer isso explicitamente. Na Rule unique, ignore os apagados:
In validation, you need to state this explicitly. In the unique Rule, ignore the deleted ones:
use Illuminate\Validation\Rule;
Rule::unique('usuarios', 'email')->whereNull('deleted_at');
Assim a validação só considera os registros ativos, e o email “liberado” pelo soft delete volta a funcionar. This way, the validation only considers active records, and the email “freed” by the soft delete works again.
Pegadinha 2: relacionamento com registro apagado
Gotcha 2: relationship with deleted records
Outra que confunde. O pedido foi soft-deletado, mas você carrega ele por um relacionamento e o item some: $cliente->pedidos; // não traz os pedidos apagados. Isso normalmente é o que você quer. Mas quando não for — tipo montar um histórico completo do cliente — o relacionamento também aceita withTrashed:
Another confusing one. The order was soft-deleted, but you load it via a relationship and the item disappears: $cliente->pedidos; // doesn't bring deleted orders. This is usually what you want. But when it isn’t — like building a complete customer history — the relationship also accepts withTrashed:
public function pedidos() {
return $this->hasMany(Pedido::class)->withTrashed();
}
Ou pontualmente, sem mexer na model: $cliente->pedidos()->withTrashed()->get();.
Or specifically, without touching the model: $cliente->pedidos()->withTrashed()->get();.
Bônus: e agora?
Bonus: what now?
Olha as tabelas do seu projeto e pergunta pra cada uma: “se alguém apagar isso por engano, dói?” Pedido, fatura, contrato, usuário — provavelmente sim. Categoria de teste que você cria e joga fora — provavelmente não. Soft delete é ótimo, mas não precisa estar em tudo; coluna a mais e query um pouco mais complexa têm um custo. Look at your project’s tables and ask for each one: “if someone deletes this by mistake, does it hurt?” Order, invoice, contract, user — probably yes. Test categories you create and throw away — probably not. Soft delete is great, but it doesn’t need to be everywhere; an extra column and slightly more complex queries have a cost.
E cuidado com a ideia de “guardar tudo pra sempre”. Dependendo do dado (LGPD, por exemplo), você pode ser obrigado a apagar de verdade depois de um tempo. Aí um job periódico com forceDelete() nos registros antigos resolve.
And be careful with the idea of “keeping everything forever.” Depending on the data (GDPR, for example), you might be required to actually delete it after a while. A periodic job with forceDelete() on old records solves that.