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

Вы включили SoftDeletes в модели User, а через месяц регистрация начала падать с ошибкой Duplicate entry for key users_email_unique. Причина: удалённая запись физически осталась в таблице, её email занимает уникальный индекс, и новый пользователь с тем же адресом зарегистрироваться не может. Это самая частая ловушка soft delete, и она вылезает не сразу, а когда откатить архитектурное решение уже дорого. Разберём, как подключить мягкое удаление, чтобы не наступить на грабли уникальных полей, связей и случайно попавших в выборку удалённых строк. Речь про Laravel 10.x и 11.x — механизм там одинаковый.
Что делает SoftDeletes на самом деле
Трейт не удаляет строку. Вызов $model->delete() проставляет текущее время в колонку deleted_at, а все обычные запросы Eloquent автоматически добавляют условие WHERE deleted_at IS NULL. Это делает глобальный скоуп SoftDeletingScope. Запись остаётся в базе, но пропадает из выборок — до тех пор, пока вы явно не попросите её вернуть.
Отсюда три следствия, которые и порождают все проблемы: строка занимает место в уникальных индексах; связанные записи по внешним ключам никуда не деваются; сырые SQL-запросы и агрегаты видят удалённое, потому что скоуп работает только через Eloquent.
Минимальное подключение
Нужны две вещи — колонка в таблице и трейт в модели.
// Миграция: добавляем deleted_at
Schema::table('posts', function (Blueprint $table) {
$table->softDeletes(); // создаёт nullable TIMESTAMP deleted_at
});
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
}
После этого Post::find(1)->delete() проставит deleted_at, а Post::all() удалённый пост уже не покажет.
Как читать удалённое и как восстанавливать
Глобальный скоуп можно снимать точечно. Три метода запроса:
withTrashed()— вернёт и живые, и удалённые записи;onlyTrashed()— только удалённые, удобно для страницы «Корзина»;restore()— обнуляетdeleted_at, запись снова видна в обычных выборках.
// Восстановление одной записи
$post = Post::withTrashed()->findOrFail($id);
$post->restore();
// Восстановление пачки
Post::onlyTrashed()
->where('deleted_at', '>', now()->subDays(30))
->restore();
// Окончательное удаление из базы
$post->forceDelete();
Проверить состояние конкретной модели: $post->trashed() вернёт true, если запись помечена удалённой.
Три места, где всё ломается
1. Уникальные индексы
Тот самый сценарий из начала. Обычный уникальный индекс на email не знает про deleted_at — для базы удалённая строка такая же реальная, как живая. Есть два рабочих подхода.
Составной уникальный индекс по паре (email, deleted_at). Пока запись жива, deleted_at IS NULL, и MySQL считает NULL несравнимыми — то есть несколько строк с одинаковым email и deleted_at = NULL база разрешит. Это ловушка: в MySQL составной уникальный индекс с NULL не защищает от дублей живых записей. Подход работает как ожидается только в PostgreSQL при использовании NULLS NOT DISTINCT (доступно с PostgreSQL 15) либо через частичный индекс.
Частичный уникальный индекс — надёжнее и переносит смысл прямо в базу: уникальность требуется только среди живых записей.
-- PostgreSQL: уникальность email только пока запись не удалена
CREATE UNIQUE INDEX users_email_active_unique
ON users (email)
WHERE deleted_at IS NULL;
В MySQL до версии 8.0.13 частичных индексов нет; там либо добавляют вычисляемую колонку, либо проверяют уникальность на уровне приложения через валидацию с Rule::unique и условием whereNull('deleted_at'). Точный синтаксис функциональных индексов сверяйте с документацией вашей СУБД — поведение отличается между MySQL, MariaDB и PostgreSQL.
2. Связи и каскад
Мягкое удаление не каскадируется автоматически. Удалив пользователя, вы не удалите его посты — они останутся видимыми. Laravel даёт событие модели для ручного каскада.
protected static function booted(): void
{
static::deleting(function (User $user) {
// Мягко удаляем связанные посты вместе с пользователем
$user->posts()->delete();
});
}
Обратная сторона: при restore() пользователя посты сами не восстановятся — вам нужно решить, восстанавливать ли их и как отличить «удалены вместе с юзером» от «удалены раньше по своей причине». Универсального ответа нет; чаще хранят отдельную метку или сравнивают deleted_at с точностью до секунды.
Soft delete превращает удаление из атомарной операции в состояние, за которым нужно следить. Вы больше не удаляете данные — вы управляете их видимостью, и эту ответственность теперь несёт каждый запрос в проекте.
3. Сырые запросы и обходные пути
Глобальный скоуп добавляет WHERE deleted_at IS NULL только к запросам через Eloquent-модель. Всё, что идёт мимо, увидит удалённые строки:
DB::table('posts')->count()— посчитает и удалённые;- прямые SQL-запросы в отчётах и дашбордах;
whereHasк связи, если сама связь определена без SoftDeletes на дочерней модели — здесь легко получить призрачные записи.
Правило простое: если строите аналитику или экспорт напрямую через DB::, добавляйте whereNull('deleted_at') руками. Забудете — цифры разъедутся.
Сравнение подходов к «удалению»
| Подход | Данные остаются | Уникальность из коробки | Когда брать |
|---|---|---|---|
Обычный delete() | Нет | Да | Логи, кэш, то, что не жаль потерять |
| SoftDeletes | Да, в той же таблице | Нет, нужен частичный индекс | Пользовательский контент, обратимые действия |
| Архивная таблица | Да, в отдельной | Да, основная таблица чистая | Большие объёмы, где мусор в индексах мешает |
SoftDeletes хорош, когда восстановление — частая и ожидаемая операция, а объём удалённого невелик относительно живого. Если таблица растёт и 80% строк — удалённые, индексы распухают, и запросы с WHERE deleted_at IS NULL начинают сканировать лишнее. Тогда смотрят в сторону выноса в архив или частичного индекса по deleted_at IS NULL, чтобы планировщик не спотыкался.
Чистка старого мусора
Мягко удалённое накапливается. Разумно физически подчищать записи старше некоторого срока. Готовая команда — model:prune, если модель реализует нужный контракт.
use Illuminate\Database\Eloquent\Prunable;
class Post extends Model
{
use SoftDeletes, Prunable;
// Что удалять физически: помеченные удалёнными более 90 дней назад
public function prunable(): Builder
{
return static::onlyTrashed()
->where('deleted_at', '<=', now()->subDays(90));
}
}
Дальше команду вешают в расписание планировщика (schedule:run по крону). Проверьте наличие и сигнатуру трейта Prunable в документации вашей версии Laravel — он появился не в самых старых релизах.
Что настроить после
Порядок действий, если внедряете soft delete на живом проекте:
- Проверьте все уникальные индексы на моделях с
SoftDeletes— переведите их в частичные или перенесите проверку в валидацию. - Найдите в коде все
DB::table()по этим таблицам и добавьтеwhereNull('deleted_at')там, где удалённое не нужно. - Опишите политику каскада: что удаляется вместе с родителем и что восстанавливается при
restore(). - Настройте
Prunableи планировщик, чтобы таблица не росла бесконечно. - Добавьте страницу «Корзина» на
onlyTrashed(), если пользователю нужно восстанавливать самому.
Мерить стоит долю удалённых строк в таблице и время выполнения запросов с фильтром по deleted_at. Если доля переваливает за половину, а индекс не частичный — это сигнал пересмотреть подход.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Laravel Eloquent: Soft Deleting (официальная документация), Laravel Eloquent: Pruning Models
Частые вопросы
А если у меня другая версия Laravel — 8 или 9?
Почему после включения SoftDeletes падает регистрация с дублем email?
Восстановится ли связанный контент при restore родителя?
Не сработало: DB::table считает удалённые записи, хотя SoftDeletes включён.
SoftDeletes замедлит запросы под нагрузкой?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.