Soft Deletes
Идея
Физическое удаление (DELETE FROM) необратимо. Для аудита, восстановления, истории — это плохо.
Soft Deletes — запись остаётся в БД, но помечается как удалённая через колонку deleted_at.
Плюсы:
- Восстановление — можно вернуть.
- История — данные сохранены.
- Аудит — когда удалили.
- Безопасность — случайное удаление не критично.
Минусы:
- Таблица растёт — нужно чистить.
- Запросы — надо помнить про
deleted_at. - Уникальность — конфликт с
unique.
Настройка
1. Трейт в модели:
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
2. Колонка в миграции:
$table->softDeletes(); // добавляет deleted_at TIMESTAMP NULL
3. Миграция:
php artisan migrate
Как работает
delete() — проставляет deleted_at = now():
$post->delete();
// UPDATE posts SET deleted_at = '2026-10-15 12:00:00' WHERE id = 1
Все стандартные запросы добавляют WHERE deleted_at IS NULL:
Post::all();
// SELECT * FROM posts WHERE deleted_at IS NULL
Удалённые записи не попадают в выборку.
Восстановление
По модели
$post = Post::withTrashed()->find(1);
$post->restore();
Массово
Post::withTrashed()->where('user_id', 1)->restore();
По ID
Post::withTrashed()->whereIn('id', [1, 2, 3])->restore();
Полное удаление
Физически:
$post->forceDelete();
Post::withTrashed()->where('user_id', 1)->forceDelete();
⚠️ Нельзя откатить.
Просмотр удалённых
withTrashed
Включая удалённые:
Post::withTrashed()->get();
onlyTrashed
Только удалённые:
Post::onlyTrashed()->get();
Условия на deleted_at
Post::where('deleted_at', '>', now()->subDays(30))->get(); // не сработает — WHERE AND
Post::onlyTrashed()->where('deleted_at', '>', now()->subDays(30))->get();
Проверка
$post->trashed(); // true / false
Связи и Soft Deletes
Удалённые связанные записи не попадают в связи:
$user->posts; // только неудалённые
Включить удалённые:
$user->posts()->withTrashed()->get();
Или загрузить связь с условием:
public function posts()
{
return $this->hasMany(Post::class)->withTrashed();
}
Автоматическая очистка
Pruning — модель сама удаляет старые записи.
use Illuminate\Database\Eloquent\MassPrunable;
class Post extends Model
{
use SoftDeletes, MassPrunable;
public function prunable()
{
return static::onlyTrashed()
->where('deleted_at', '<=', now()->subDays(90));
}
}
Запуск:
php artisan model:prune
В планировщике:
// routes/console.php
Schedule::command('model:prune')->daily();
Что происходит:
- Находит записи, удалённые >90 дней назад.
- Физически удаляет через
forceDelete.
События
С Soft Deletes работают события:
deleting— передdelete().deleted— после.restoring— передrestore().restored— после.forceDeleting,forceDeleted— при force delete.
Пример:
protected static function booted(): void
{
static::deleting(function (Post $post) {
// удалить комментарии при soft delete
$post->comments()->delete();
});
static::restoring(function (Post $post) {
// восстановить комментарии
$post->comments()->withTrashed()->restore();
});
}
Уникальность
Проблема: если email уникален и запись soft-deleted — новая с тем же email не создастся.
Решение 1 — составной индекс:
$table->unique(['email', 'deleted_at']);
⚠️ В PostgreSQL NULL не равен NULL — работает. ⚠️ В MySQL — тоже работает.
Решение 2 — частичный индекс:
$table->unique('email')->whereNull('deleted_at');
Только для PostgreSQL.
Решение 3 — при удалении менять email:
protected static function booted(): void
{
static::deleting(function (User $user) {
if (!$user->isForceDeleting()) {
$user->email = "deleted_{$user->id}_{$user->email}";
$user->saveQuietly();
}
});
}
Восстановление связей
Если у модели есть дети, они тоже могли быть удалены:
class Post extends Model
{
use SoftDeletes;
public function restoreWithChildren(): void
{
DB::transaction(function () {
$this->comments()->withTrashed()->restore();
$this->restore();
});
}
}
Практика
// Удалить
$post = Post::find(1);
$post->delete();
// Восстановить
$post->restore();
// Проверить
$post->trashed(); // false (уже восстановлен)
// Все с удалёнными
$all = Post::withTrashed()->get();
// Только удалённые
$onlyTrashed = Post::onlyTrashed()->get();
// Полное удаление
$post->forceDelete();
// Массовое восстановление
Post::onlyTrashed()
->where('deleted_at', '>', now()->subWeek())
->restore();
// Массовая физическая очистка
Post::onlyTrashed()
->where('deleted_at', '<', now()->subYear())
->forceDelete();
Частые ошибки
1. Забыли трейт SoftDeletes:
$post->delete(); // удалит физически
2. withTrashed() без модели:
Post::withTrashed(); // вернёт Builder, не результат
3. Уникальность email:
$user->delete();
User::create(['email' => $user->email]); // ошибка unique
4. Забыли про связи:
$user->delete();
// $user->posts остались неудалёнными
5. Не чистят старые:
// через год: 10 000 000 soft-deleted записей
Итог
- Soft Deletes —
deleted_atвместо физического удаления. withTrashed— включить удалённые.onlyTrashed— только удалённые.restore,forceDelete— восстановить, удалить навсегда.- Pruning — автоочистка.
- Уникальность — составной индекс.
- Связи — не забывай про них.
Дальше — скоупы.