Soft deletes в Laravel: удаление «с сохранением»
Soft delete — это удаление «по-настоящему не до конца»: строка не убирается из базы, а помечается отметкой времени в колонке deleted_at. Всё, что помечено, обычные запросы Eloquent не видят, а специальными методами — можно вернуть (restore) или добить окончательно (forceDelete). Включается трейтом SoftDeletes одной строкой. Понимать тему нужно, потому что soft delete меняет поведение всех запросов к модели — и половина его сюрпризов встречается уже в проде: «удалённый» пользователь снова ловит ошибку дубликата email, uniquе-валидация не видит удалённые записи, а связи продолжают тянуть данные из «удалённых» родителей.
Зачем это нужно
Боль из практики: в интернет-магазине заказы удаляли «жёстко». Пользователь отменил заказ — запись исчезла из базы. Через полгода бухгалтерия запросила отчёт: сколько заказов было отменено за год и на какую сумму. Данных нет — они физически удалены.
php// ПЛОХО: удаление без следа — история потеряна навсегда
$order->delete(); // DELETE FROM orders WHERE id = 5
Перешли на soft deletes, и проблема с отчётом ушла — «удалённые» заказы лежат в базе. Но всплыла другая мина: у таблицы users есть уникальный индекс на email, а пользователи стали жаловаться, что не могут зарегистрироваться второй раз:
php// Сценарий: пользователь удалил аккаунт → строка осталась с deleted_at
$user->delete(); // email 'anna@mail.ru' физически в таблице, но «удалён»
// Через год тот же email пытается зарегистрироваться:
// INSERT упадёт — уникальный индекс не знает про deleted_at
// Unique violation: users.email_unique
php// ХОРОШО: уникальность с учётом удалённых записей
// MySQL 5.7.11+/MariaDB: составной индекс с NULL-хаком
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email');
$table->softDeletes();
$table->unique(['email', 'deleted_at']); // у мягко удалённых deleted_at NOT NULL → MySQL допускает дубликаты
});
Ошибка не в soft deletes, а в наивном предположении «раздался только deleted_at — всё остальное живёт как раньше». Индексы, валидация, связи — всё нужно проверять на поведение с удалёнными строками.
Как это работает
Подключение — трейт и колонка:
phpuse Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes; // и всё: обычные запросы deleted_at не видят
}
php// Миграция
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('status');
$table->softDeletes(); // nullable колонка deleted_at + индекс
});
Три состояния строки и переходы между ними:
php// Мягкое удаление: заполняем deleted_at
$order->delete(); // UPDATE orders SET deleted_at = NOW() WHERE id = 5
// Строка всё ещё в базе — но не видна обычным запросам:
Order::find(5); // null — у запроса молча добавился WHERE deleted_at IS NULL
Order::count(); // без удалённых
Order::all();
// Работа с удалёнными
Order::withTrashed()->find(5); // ищет и среди удалённых
Order::onlyTrashed()->get(); // только удалённые
$order->restore(); // обнуляет deleted_at — запись «живая» снова
Order::withTrashed()->where('user_id', 7)->restore();
// Жёсткое удаление — физическое:
$order->forceDelete(); // DELETE FROM orders WHERE id = 5 — теперь исчезло навсегда
Проверка состояния:
php$order->trashed(); // true, если запись в корзине
$order->deleted_at; // Carbon или null
Связи и soft deletes — самый неочевидный узел. Связь подхватывает поведение через свой класс:
phpclass Comment extends Model
{
use SoftDeletes;
}
// Связь на модель с SoftDeletes даёт методы:
$order->comments()->withTrashed()->get(); // включая удалённые комментарии
$order->comments()->onlyTrashed()->get();
Но связь «наоборот» работает иначе: если у заказа есть user_id на модель без SoftDeletes — «удалённый» заказ всё равно вернёт живого пользователя. А если пользователь «удалён», заказ с deleted_at IS NULL молча получит null в $order->user — ссылка на пользователя никуда не делась, просто запрос не увидел строку.
Как это выглядит в Laravel
deleted_atв$casts— трейт сам приводит колонку к Carbon, даты работают:$order->deleted_at->diffInDays(now()).MySQL: NULL в составном индексе— стандартный приём для уникальности с soft deletes; в PostgreSQL вместо него чаще строят частичный индекс (CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL).- Панель «корзины» — админка с восстановлением строится на
onlyTrashed(): список удалённого + кнопкаrestore(). - Observer всё ещё видит удалённые записи — события
deleted,restored,forceDeletedсрабатывают штатно, что удобно для логирования.
Плохо / Хорошо
| Плохо | Хорошо |
|---|---|
Уникальный индекс на email без учёта deleted_at | Составной индекс [email, deleted_at] или частичный |
Жёсткий DELETE для данных с бизнес-историей | SoftDeletes + лог аудита |
withTrashed() на каждый запрос «на всякий случай» | Точечное применение, где нужны удалённые |
| SoftDeletes везде без раздумий | Оценка: нужны ли данные после удаления |
Забытая связь withTrashed в eager loading | Проверять, что тянется с удалёнными |
restore() без проверки состояния заказа | Валидировать, что восстановление согласовано |
Подводный камень
- Уникальные индексы не знают про
deleted_at. Классический сценарий «пользователь удалил аккаунт и не может зарегистрироваться снова». Решения: составной индекс[email, deleted_at](MySQL), частичный индекс (PostgreSQL) или при регистрации —withTrashed()->where('email', $email)с ручной обработкой. - Каскад при
delete()не срабатывает. Удаление родителя не помечает детей:$order->delete()не трогаетorder_items— те останутся живыми и могут «утащить» данные в отчёты. Каскад нужно организовывать явно: событиеdeletedмодели,deletingдля рекурсивного удаления, илиON DELETE SET NULLв базе — по ситуации. whereпо колонкам удалённых связей показывает живое с мёртвым. ЗапросOrder::with('user')->get()вернёт заказы, у которых$order->user === null— если пользователь удалён. Если это нежелательно —whereHas('user')отфильтрует такие строки.- Массовые операции ведут себя иначе.
$order->delete()на модели — с событиемdeleted. АOrder::where('status', 'cancelled')->delete()на построителе заполняетdeleted_atбез событий моделей на каждую строку — если логика завязана на события, она молча не выполнится. Trashedзаписи всё равно занимают индексы и место. Soft delete — не бесплатная корзина: чем дольше живут «удалённые», тем больше раздувается таблица и индексы. Для записи с историей «мусор убирают» регулярной задачей —forceDeleteстарых удалённых, обычно через планировщик.
Мини-шпаргалка
phpuse SoftDeletes; // модель
$table->softDeletes(); // миграция: deleted_at + индекс
$order->delete(); // мягко
$order->trashed(); // в корзине?
$order->restore(); // восстановить
$order->forceDelete(); // удалить физически
Order::withTrashed()->find(5); // искать и среди удалённых
Order::onlyTrashed()->where('user_id', 7)->get(); // только удалённые
Order::withTrashed()->where(...)->restore(); // массовое восстановление
// Связи с удалёнными
$order->comments; // только живые
$order->comments()->withTrashed()->get(); // все, включая удалённые
Три вывода
- Soft delete — это не удаление, а метка
deleted_at: обычные запросы фильтруют её молча, аwithTrashed/onlyTrashed/restoreдают полный доступ к корзине. - Главная цена soft deletes — не колонка, а связанная инфраструктура: уникальные индексы, каскад, внешние ключи — всё нужно проверять на поведение с «удалёнными» записями.
- Жёсткое удаление остаётся за вами: без регулярной чистки «удалённые» строки живут вечно и раздувают базу, а
forceDelete— единственный способ избавиться по-настоящему.
Вопрос-ловушка
Спрашивают так: «Что делает withTrashed()?» Простой ответ «показывает удалённые» — половина дела. Сильный — уточнить, что обычные запросы Eloquent фильтруют deleted_at молча, а withTrashed снимает фильтр: запрос превращается из «только живые» в «все без исключения». Хитрый поворот: «Пользователь удалил аккаунт. Второй раз с тем же email регистрируется — что произойдёт?» Ожидаемый ответ: если уникальный индекс простой — INSERT упадёт с unique violation, потому что индекс не знает про deleted_at; лечение — составной или частичный индекс. И ещё: «Чем Order::where(...)->delete() отличается от перебора моделей с delete()?» — первый ставит deleted_at одним запросом без событий моделей, второй — с событиями; выбор зависит от того, нужна ли логика на удаление каждой строки.