Soft delete в Laravel: рабочие приёмы и грабли из практики
Мягкое удаление в Laravel выглядит как одна строка в модели, но ломается на уникальных индексах, каскадах и глобальных скоупах. Разбираем приёмы, которые выдерживают прод.

Мягкое удаление в Laravel добавляется трейтом SoftDeletes и одной миграцией. На демо-проекте это работает идеально. На боевом всплывают три проблемы: пользователь не может повторно зарегистрироваться под старым email, связанные записи остаются живыми, а отчёты внезапно считают удалённых. Разберём приёмы, которые эти грабли обходят. Речь про Laravel 10.x и 11.x — API трейта в них совпадает.
Что такое soft delete в Laravel и когда он оправдан
При мягком удалении строка не покидает таблицу. Eloquent проставляет текущее время в колонку deleted_at, а глобальный скоуп SoftDeletingScope добавляет ко всем запросам условие where deleted_at is null. Запись как бы исчезает из выборок, но лежит в базе и восстанавливается вызовом restore().
Это оправдано, когда удаление обратимо по бизнес-требованию: корзина, отмена заказа, восстановление аккаунта, аудит. Это лишнее, когда данные обязаны исчезать по закону (запросы на удаление персональных данных) — там нужен настоящий forceDelete() или отдельная процедура анонимизации.
Базовая установка. Добавьте колонку и подключите трейт:
// миграция, Laravel 11.x
Schema::table('orders', function (Blueprint $table) {
$table->softDeletes(); // добавит nullable timestamp deleted_at
});
// модель
use Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes;
}
После этого $order->delete() не трогает данные физически, а Order::all() удалённые не вернёт.
Грабля первая: уникальные индексы против soft delete
Самый частый сбой. У пользователя уникальный email. Вы мягко удалили запись — строка осталась в таблице. Пользователь пытается зарегистрироваться заново, база возвращает SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry. С точки зрения приложения email свободен, с точки зрения СУБД — занят удалённой строкой.
Два рабочих подхода.
Подход А: составной уникальный индекс с deleted_at
Делаете уникальным не сам email, а пару (email, deleted_at). Пока запись жива, deleted_at равен NULL, и повторный живой email невозможен. После удаления deleted_at заполнено — новая живая запись с тем же email проходит.
Schema::table('users', function (Blueprint $table) {
$table->unique(['email', 'deleted_at']);
});
Тонкость про NULL. В MySQL и PostgreSQL несколько строк с NULL в уникальном индексе допускаются — то есть две удалённые записи с одинаковым email и deleted_at IS NULL невозможны только пока они живы. Но два удаления одного email в разное время дадут разные deleted_at и конфликта не вызовут. Живая запись всегда одна — это и требуется.
Подход Б: валидация с учётом удалённых
Индекс защищает базу, но пользователю нужна понятная ошибка, а не 500. Правило unique в Laravel по умолчанию считает удалённые записи занятыми, потому что смотрит прямо в таблицу мимо скоупа. Ограничьте проверку живыми строками:
use Illuminate\Validation\Rule;
$rules = [
'email' => [
'required', 'email',
Rule::unique('users')->whereNull('deleted_at'),
],
];
Так валидатор считает email свободным, если единственная запись с ним — удалённая.
Грабля вторая: связанные записи остаются живыми
Мягкое удаление заказа не удаляет его позиции. $order->delete() помечает только сам заказ; строки в order_items продолжают существовать и, если у них тоже есть soft delete, остаются видимыми в своих выборках.
Каскад придётся описывать самому. Внешние ключи с ON DELETE CASCADE здесь не помогут — физического удаления не происходит, триггер каскада не срабатывает.
class Order extends Model
{
use SoftDeletes;
protected static function booted(): void
{
// мягко удаляем позиции вместе с заказом
static::deleting(function (Order $order) {
if (! $order->isForceDeleting()) {
$order->items()->delete();
}
});
// и восстанавливаем их обратно
static::restoring(function (Order $order) {
$order->items()->onlyTrashed()->restore();
});
}
}
Каскад в soft delete всегда ручной. Если вы не написали обработчик deleting, дочерние записи живут своей жизнью — и рано или поздно кто-то найдёт «сироту», привязанную к удалённому родителю.
Проверка isForceDeleting() важна: при жёстком удалении внешние ключи с каскадом уберут детей сами, и повторное удаление через трейт вызовет лишние запросы.
Грабля третья: удалённые попадают в отчёты и связи
Глобальный скоуп прячет удалённые только у самой модели. Через отношения они утекают. Классический пример: hasMany без soft delete на дочерней модели вернёт записи, чей родитель мягко удалён, — потому что скоуп родителя к дочернему запросу не применяется.
Что помогает на практике:
- Явные выборки. Для отчётов и агрегатов используйте
withTrashed()осознанно, а не по привычке — иначе цифры включат удалённых. - Проверка родителя в запросе. Если дочерняя таблица тоже мягко удаляется, синхронизируйте её через обработчик
deletingвыше, а не надейтесь на скоуп родителя. - onlyTrashed для админки корзины. Экран «удалённые заказы» строится на
Order::onlyTrashed()->get().
Три метода, которые надо держать в голове:
| Метод | Что возвращает | Когда применять |
|---|---|---|
Model::all() | только живые | обычные пользовательские выборки |
Model::withTrashed() | живые и удалённые | отчёты, аудит, поиск по истории |
Model::onlyTrashed() | только удалённые | экран корзины, массовое восстановление |
Восстановление: одиночное и массовое
Одну запись возвращает restore(). Но find($id) удалённую не найдёт — сначала снимите скоуп:
$order = Order::withTrashed()->findOrFail($id);
$order->restore(); // сбросит deleted_at в null, вызовет события restoring/restored
Массовое восстановление по фильтру — без загрузки моделей в память:
// вернуть всё, что удалили за последний час
Order::onlyTrashed()
->where('deleted_at', '>=', now()->subHour())
->restore();
Учтите: массовый restore() через query builder не запускает обработчики restoring/restored на моделях. Если восстановление детей завязано на событие родителя, при массовой операции дочерние записи не восстановятся. Для сценариев с каскадом восстанавливайте по одной модели или дублируйте логику каскада в самой операции.
Очистка корзины: когда удалять физически
Мягко удалённые записи копятся и раздувают таблицу, портя планы запросов. Введите срок жизни в корзине и чистите по расписанию. forceDelete() убирает строку окончательно и запускает каскад внешних ключей.
// команда artisan, запускается по расписанию
Order::onlyTrashed()
->where('deleted_at', '<=', now()->subDays(30))
->each(function (Order $order) {
$order->forceDelete(); // сработает isForceDeleting() в обработчике deleting
});
Здесь важен each, а не forceDelete() на query builder: чтобы отработали события и каскад на связях, нужны инстансы моделей. Для больших объёмов используйте chunkById, иначе загрузите всю корзину в память.
Что делать дальше
Пройдитесь по чек-листу перед выкатом soft delete на боевую таблицу:
- Есть ли уникальные индексы — переделайте их в составные с
deleted_atи поправьте правилаuniqueв валидации. - Есть ли дочерние связи — напишите обработчики
deletingиrestoring, проверьтеisForceDeleting(). - Прогоните отчёты и агрегаты — не считают ли они удалённых через отношения.
- Заведите команду очистки корзины по расписанию и добавьте её в планировщик.
Следующий шаг для продакшена — индекс по deleted_at на больших таблицах и метрика роста числа мягко удалённых строк: если корзина растёт быстрее, чем чистится, планы запросов деградируют. Инвалидация кеша при восстановлении — отдельная тема, здесь её не разбираем.
Субтитры и таймкоды: Расшифровка речи и субтитры по правилам читаемости: 42 знака в строке, две строки, минимум секунда на реплику.
Спросить за 40 ₽Источники: Laravel — Eloquent: Getting Started (Soft Deleting), Laravel — Validation Rule: unique
Частые вопросы
А если у меня другая версия — Laravel 8 или 9?
Почему restore() не восстанавливает связанные записи?
Массовый restore через query builder не сработал на связях — почему?
Не сломает ли soft delete существующие уникальные индексы?
Можно ли использовать soft delete для данных, которые обязаны исчезать по закону?
Как найти удалённую запись, если find() её не возвращает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.