Soft delete в Laravel: SoftDeletes, флаг или архив — что выбрать
Трейт SoftDeletes кажется очевидным решением для «удаления с возможностью восстановить», но он ломает уникальные индексы и молча искажает подсчёты. Разбираем три подхода и цену каждого.

Задача звучит просто: пользователь удаляет запись, но данные должны остаться — на случай отмены, аудита или требований бухгалтерии. Первое, что находит разработчик Laravel, — трейт SoftDeletes. Пять минут работы: добавили трейт, добавили колонку, готово. Проблемы начинаются позже: перестаёт работать уникальный индекс на email, счётчик заказов показывает лишнее, а джойн из чужого запроса внезапно тянет «удалённые» строки, потому что тот запрос не знал про глобальный скоуп.
Ниже — три рабочих подхода к мягкому удалению в Laravel 11.x (для 10.x всё работает так же), с ценой каждого и понятным критерием выбора. Инвалидацию кеша и удаление связанных файлов из S3 не трогаем — это отдельная тема.
Подход 1. Трейт SoftDeletes
Штатный механизм Eloquent. Добавляет колонку deleted_at и глобальный скоуп, который автоматически подмешивает WHERE deleted_at IS NULL ко всем запросам через модель.
// app/Models/Order.php
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Order extends Model
{
use SoftDeletes;
}
// миграция
Schema::table('orders', function (Blueprint $table) {
$table->softDeletes(); // добавляет nullable timestamp deleted_at
});Дальше всё работает почти прозрачно:
$order->delete(); // проставит deleted_at, строка останется
$order->restore(); // вернёт, обнулив deleted_at
$order->forceDelete(); // удалит физически
// достать вместе с удалёнными
Order::withTrashed()->get();
// только удалённые
Order::onlyTrashed()->get();
// проверить состояние
$order->trashed(); // true / falseЧто вы получаете: минимум кода, готовые методы, интеграцию с маршрутным биндингом (Route::bind с withTrashed()) и событиями deleting/restoring.
Чем платите
- Уникальные индексы ломаются. Индекс
UNIQUE(email)не даст создать нового пользователя с тем же email, пока в таблице лежит «удалённый» с этим email. Решение — составной частичный индекс, о нём ниже. - Скоуп действует только через модель. Любой
DB::table('orders'), сырой SQL или джойн из другой модели увидит удалённые строки. Аналитический запрос легко посчитает выручку по отменённым заказам. - Каскад не автоматический. При soft delete родителя дочерние записи не удаляются сами. Нужно вешать на событие
deletingручное удаление связей — иначе получите «висящих сирот» и рассинхрон.
Как чинить уникальность
В PostgreSQL — частичный уникальный индекс, который игнорирует удалённые строки:
// миграция, PostgreSQL 16
DB::statement('
CREATE UNIQUE INDEX users_email_active_unique
ON users (email)
WHERE deleted_at IS NULL
');В MySQL 8.x частичных индексов нет. Обходной путь — генерируемая колонка: пока deleted_at IS NULL, в неё пишется email, иначе значение зависит от id, и коллизий не будет. Точный синтаксис GENERATED ALWAYS AS сверяйте с документацией вашей версии MySQL — поведение UNIQUE с NULL-значениями между версиями отличается.
Подход 2. Ручной булев флаг
Своя колонка is_archived (или status) без трейта и без глобального скоупа. Фильтрацию пишете сами.
// вместо delete()
$order->update(['is_archived' => true, 'archived_at' => now()]);
// активные — явно
Order::where('is_archived', false)->get();Чтобы не дублировать where по всему коду, добавляют локальный скоуп — он, в отличие от глобального, включается только там, где вы его позвали:
// в модели
public function scopeActive($query)
{
return $query->where('is_archived', false);
}
// в коде
Order::active()->paginate(20);Что выигрываете: полный контроль. Ни один запрос не фильтруется втихую, поведение видно в коде, аналитике проще — вы явно решаете, включать архив или нет. Что теряете: delete() перестаёт быть «удалением», приходится помнить про ->active() в каждом пользовательском запросе, а забытый скоуп означает утечку архивных данных в интерфейс.
Разница философская: SoftDeletes делает удалённое невидимым по умолчанию и заставляет вспоминать проwithTrashed(). Ручной флаг делает всё видимым по умолчанию и заставляет вспоминать проactive(). Первый прячет данные и рискует их потерять из виду, второй показывает лишнее и рискует показать не тому.
Подход 3. Отдельная таблица-архив
При удалении строка физически переносится в таблицу orders_archive (или в отдельную БД). Рабочая таблица остаётся чистой.
DB::transaction(function () use ($order) {
// копируем в архив со всеми полями
DB::table('orders_archive')->insert(
array_merge($order->getAttributes(), ['archived_at' => now()])
);
$order->forceDelete(); // физически убираем из рабочей таблицы
});Что выигрываете: рабочая таблица не пухнет от «мусорных» строк, индексы работают на живых данных, аналитические запросы по определению не видят архив. Что теряете: восстановление — это обратный перенос со всеми связями, схема архива должна поспевать за миграциями основной таблицы, а любая связь на удалённую запись через foreign key потребует отдельной стратегии.
Сравнение
| Критерий | SoftDeletes | Ручной флаг | Таблица-архив |
|---|---|---|---|
| Кода на старте | Минимум | Средне | Много |
| Уникальные индексы | Ломаются, нужен частичный | Работают | Работают |
| Риск показать удалённое | Низкий (скрыто по умолчанию) | Выше (нужен явный скоуп) | Нулевой |
| Риск посчитать удалённое в аналитике | Высокий (сырой SQL видит всё) | Средний | Нулевой |
| Размер рабочей таблицы | Растёт | Растёт | Не растёт |
| Простота восстановления | Одна строка | Одна строка | Обратный перенос |
Когда что выбирать
- SoftDeletes — когда удаление редкое, восстановление частое, а данных немного. Классика: карточки товаров, документы, задачи. Условие — вы готовы поставить частичные индексы и следить, чтобы аналитика ходила не через сырой SQL, а через модель.
- Ручной флаг — когда «удалённые» записи на самом деле не удалены, а сменили статус, и их регулярно показывают: архив заказов, отключённые аккаунты, снятые с публикации посты. Флаг честнее описывает домен, чем
deleted_at. - Таблица-архив — когда рабочая таблица большая и горячая, удаления массовые, а к архиву обращаются раз в квартал ради аудита. Цена восстановления здесь окупается скоростью основных запросов.
Что ломается на практике
- Забытый каскад. Удалили пользователя через SoftDeletes, а его комментарии остались активными и ведут в никуда. Вешайте очистку связей на событие
deletingмодели. - Массовое удаление мимо скоупа.
Order::query()->delete()при SoftDeletes сработает мягко, ноDB::table('orders')->delete()сотрёт строки физически — трейт тут не участвует. - Уникальность «всплывает» через месяцы. Индекс поставили без учёта soft delete, всё работало, пока никто не пытался пересоздать удалённую запись. Проверяйте это сразу.
- Аналитика по мягко удалённым. Дашборд считает выручку сырым запросом и включает отменённые заказы. Симптом — цифры в отчёте выше, чем в интерфейсе.
Что делать дальше
Прежде чем добавлять трейт, ответьте на один вопрос: удалённая запись — это исчезнувшая сущность или сменившая статус? Если первое — SoftDeletes, если второе — флаг. После внедрения проверьте три вещи в проде: работают ли уникальные индексы при пересоздании записи, не течёт ли архив в аналитические запросы и удаляются ли связанные данные. Мерить стоит рост «мёртвых» строк в горячих таблицах: когда доля удалённых переваливает за заметную часть, пора думать о переносе в архив или физической чистке по расписанию.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Laravel Eloquent: Soft Deleting (официальная документация), PostgreSQL 16: Partial Indexes
Частые вопросы
Почему после подключения SoftDeletes перестал работать уникальный индекс?
А если у меня Laravel 10, а не 11 — трейт работает так же?
Как восстановить запись, удалённую через forceDelete?
Удаляются ли связанные записи автоматически при soft delete?
Почему мой дашборд считает больше, чем показывает интерфейс?
Не сработала restore() — запись не вернулась. В чём дело?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.