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 — всё остальное живёт как раньше». Индексы, валидация, связи — всё нужно проверять на поведение с удалёнными строками.

Как это работает

Подключение — трейт и колонка:

php
use 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 — самый неочевидный узел. Связь подхватывает поведение через свой класс:

php
class 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() без проверки состояния заказаВалидировать, что восстановление согласовано

Подводный камень

  1. Уникальные индексы не знают про deleted_at. Классический сценарий «пользователь удалил аккаунт и не может зарегистрироваться снова». Решения: составной индекс [email, deleted_at] (MySQL), частичный индекс (PostgreSQL) или при регистрации — withTrashed()->where('email', $email) с ручной обработкой.
  2. Каскад при delete() не срабатывает. Удаление родителя не помечает детей: $order->delete() не трогает order_items — те останутся живыми и могут «утащить» данные в отчёты. Каскад нужно организовывать явно: событие deleted модели, deleting для рекурсивного удаления, или ON DELETE SET NULL в базе — по ситуации.
  3. where по колонкам удалённых связей показывает живое с мёртвым. Запрос Order::with('user')->get() вернёт заказы, у которых $order->user === null — если пользователь удалён. Если это нежелательно — whereHas('user') отфильтрует такие строки.
  4. Массовые операции ведут себя иначе. $order->delete() на модели — с событием deleted. А Order::where('status', 'cancelled')->delete() на построителе заполняет deleted_at без событий моделей на каждую строку — если логика завязана на события, она молча не выполнится.
  5. Trashed записи всё равно занимают индексы и место. Soft delete — не бесплатная корзина: чем дольше живут «удалённые», тем больше раздувается таблица и индексы. Для записи с историей «мусор убирают» регулярной задачей — forceDelete старых удалённых, обычно через планировщик.

Мини-шпаргалка

php
use 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();         // все, включая удалённые

Три вывода

  1. Soft delete — это не удаление, а метка deleted_at: обычные запросы фильтруют её молча, а withTrashed/onlyTrashed/restore дают полный доступ к корзине.
  2. Главная цена soft deletes — не колонка, а связанная инфраструктура: уникальные индексы, каскад, внешние ключи — всё нужно проверять на поведение с «удалёнными» записями.
  3. Жёсткое удаление остаётся за вами: без регулярной чистки «удалённые» строки живут вечно и раздувают базу, а forceDelete — единственный способ избавиться по-настоящему.

Вопрос-ловушка

Спрашивают так: «Что делает withTrashed()?» Простой ответ «показывает удалённые» — половина дела. Сильный — уточнить, что обычные запросы Eloquent фильтруют deleted_at молча, а withTrashed снимает фильтр: запрос превращается из «только живые» в «все без исключения». Хитрый поворот: «Пользователь удалил аккаунт. Второй раз с тем же email регистрируется — что произойдёт?» Ожидаемый ответ: если уникальный индекс простой — INSERT упадёт с unique violation, потому что индекс не знает про deleted_at; лечение — составной или частичный индекс. И ещё: «Чем Order::where(...)->delete() отличается от перебора моделей с delete()?» — первый ставит deleted_at одним запросом без событий моделей, второй — с событиями; выбор зависит от того, нужна ли логика на удаление каждой строки.