Soft deletes (the SoftDeletes trait) don't actually remove a row — they set a deleted_at timestamp and Eloquent silently excludes it from normal queries. That silence is the whole feature, and also the thing people trip over.
Including soft-deleted rows: withTrashed()
$allUsers = User::withTrashed()->get(); // includes soft-deleted rows
Only the soft-deleted rows: onlyTrashed()
$deletedUsers = User::onlyTrashed()->get();
Restoring a soft-deleted row
$user = User::onlyTrashed()->find($id);
$user->restore();
Or in one line:
User::onlyTrashed()->where('id', $id)->restore();
Permanently deleting it
User::onlyTrashed()->where('id', $id)->forceDelete();
The relationship gotcha
This is the part that causes real bugs: a soft-deleted parent's related rows don't show up through a normal relationship query either, because the relationship query goes through the same SoftDeletes global scope.
// A post's soft-deleted comments are invisible here by default:
$post->comments;
// Include them explicitly:
$post->comments()->withTrashed()->get();
This matters most for withCount() and eager-loading: a count that silently excludes soft-deleted related rows can look like a data bug when it's actually the intended behavior — just not the behavior you expected in that specific query.
Route-model binding also respects the soft-delete scope
A route such as /users/{user} normally will not resolve a soft-deleted User, even though the row still exists. That is usually desirable for public routes. Administrative "trash" screens need to opt into trashed models deliberately rather than assuming route-model binding behaves differently from an ordinary Eloquent query.
Restoring a parent does not restore its children automatically
If deleting a parent also soft-deletes related rows through model events or application code, $parent->restore() only restores that parent unless you explicitly implement restoration for the related models. The same asymmetry applies to permanent deletion. Treat cascading soft-delete behavior as application policy and test it; a database foreign-key cascade only applies to actual deletes, not to setting deleted_at.
Events are different for delete(), restore(), and forceDelete()
Soft-deleting and restoring models fire lifecycle events that observers can react to. A permanent delete follows the force-delete path instead. This matters if search indexes, audit logs, files, or external systems are synchronized from model events — restoring a row may need to republish state that was removed when the model was soft-deleted.
Use prunable retention for data that should not stay in trash forever
Soft delete is often a temporary recovery window rather than indefinite storage. If policy says deleted records should be purged after a retention period, make that an explicit scheduled cleanup rather than relying on an administrator to empty trash manually. The key is to distinguish "recoverable deletion" from "permanent archival"; soft deletes are convenient for the first, but they are not a substitute for a real audit/history model when historical records must be retained permanently.
Check for null before calling restore()
find($id) can still return null, even inside onlyTrashed(). For controller code, use findOrFail() when a missing trashed record should become a 404, or handle the null case explicitly before calling restore(). Otherwise the restore path turns a normal "record not found" condition into a method call on null.