Урок 05

Soft Deletes

Идея

Физическое удаление (DELETE FROM) необратимо. Для аудита, восстановления, истории — это плохо.

Soft Deletes — запись остаётся в БД, но помечается как удалённая через колонку deleted_at.

Плюсы:

  • Восстановление — можно вернуть.
  • История — данные сохранены.
  • Аудит — когда удалили.
  • Безопасность — случайное удаление не критично.

Минусы:

  • Таблица растёт — нужно чистить.
  • Запросы — надо помнить про deleted_at.
  • Уникальность — конфликт с unique.

Настройка

1. Трейт в модели:

use Illuminate\Database\Eloquent\SoftDeletes;

class Post extends Model
{
    use SoftDeletes;
}

2. Колонка в миграции:

$table->softDeletes();   // добавляет deleted_at TIMESTAMP NULL

3. Миграция:

php artisan migrate

Как работает

delete() — проставляет deleted_at = now():

$post->delete();
// UPDATE posts SET deleted_at = '2026-10-15 12:00:00' WHERE id = 1

Все стандартные запросы добавляют WHERE deleted_at IS NULL:

Post::all();
// SELECT * FROM posts WHERE deleted_at IS NULL

Удалённые записи не попадают в выборку.

Восстановление

По модели

$post = Post::withTrashed()->find(1);
$post->restore();

Массово

Post::withTrashed()->where('user_id', 1)->restore();

По ID

Post::withTrashed()->whereIn('id', [1, 2, 3])->restore();

Полное удаление

Физически:

$post->forceDelete();
Post::withTrashed()->where('user_id', 1)->forceDelete();

⚠️ Нельзя откатить.

Просмотр удалённых

withTrashed

Включая удалённые:

Post::withTrashed()->get();

onlyTrashed

Только удалённые:

Post::onlyTrashed()->get();

Условия на deleted_at

Post::where('deleted_at', '>', now()->subDays(30))->get();   // не сработает — WHERE AND
Post::onlyTrashed()->where('deleted_at', '>', now()->subDays(30))->get();

Проверка

$post->trashed();   // true / false

Связи и Soft Deletes

Удалённые связанные записи не попадают в связи:

$user->posts;   // только неудалённые

Включить удалённые:

$user->posts()->withTrashed()->get();

Или загрузить связь с условием:

public function posts()
{
    return $this->hasMany(Post::class)->withTrashed();
}

Автоматическая очистка

Pruning — модель сама удаляет старые записи.

use Illuminate\Database\Eloquent\MassPrunable;

class Post extends Model
{
    use SoftDeletes, MassPrunable;

    public function prunable()
    {
        return static::onlyTrashed()
            ->where('deleted_at', '<=', now()->subDays(90));
    }
}

Запуск:

php artisan model:prune

В планировщике:

// routes/console.php
Schedule::command('model:prune')->daily();

Что происходит:

  • Находит записи, удалённые >90 дней назад.
  • Физически удаляет через forceDelete.

События

С Soft Deletes работают события:

  • deleting — перед delete().
  • deleted — после.
  • restoring — перед restore().
  • restored — после.
  • forceDeleting, forceDeleted — при force delete.

Пример:

protected static function booted(): void
{
    static::deleting(function (Post $post) {
        // удалить комментарии при soft delete
        $post->comments()->delete();
    });

    static::restoring(function (Post $post) {
        // восстановить комментарии
        $post->comments()->withTrashed()->restore();
    });
}

Уникальность

Проблема: если email уникален и запись soft-deleted — новая с тем же email не создастся.

Решение 1 — составной индекс:

$table->unique(['email', 'deleted_at']);

⚠️ В PostgreSQL NULL не равен NULL — работает. ⚠️ В MySQL — тоже работает.

Решение 2 — частичный индекс:

$table->unique('email')->whereNull('deleted_at');

Только для PostgreSQL.

Решение 3 — при удалении менять email:

protected static function booted(): void
{
    static::deleting(function (User $user) {
        if (!$user->isForceDeleting()) {
            $user->email = "deleted_{$user->id}_{$user->email}";
            $user->saveQuietly();
        }
    });
}

Восстановление связей

Если у модели есть дети, они тоже могли быть удалены:

class Post extends Model
{
    use SoftDeletes;

    public function restoreWithChildren(): void
    {
        DB::transaction(function () {
            $this->comments()->withTrashed()->restore();
            $this->restore();
        });
    }
}

Практика

// Удалить
$post = Post::find(1);
$post->delete();

// Восстановить
$post->restore();

// Проверить
$post->trashed();   // false (уже восстановлен)

// Все с удалёнными
$all = Post::withTrashed()->get();

// Только удалённые
$onlyTrashed = Post::onlyTrashed()->get();

// Полное удаление
$post->forceDelete();

// Массовое восстановление
Post::onlyTrashed()
    ->where('deleted_at', '>', now()->subWeek())
    ->restore();

// Массовая физическая очистка
Post::onlyTrashed()
    ->where('deleted_at', '<', now()->subYear())
    ->forceDelete();

Частые ошибки

1. Забыли трейт SoftDeletes:

$post->delete();   // удалит физически

2. withTrashed() без модели:

Post::withTrashed();   // вернёт Builder, не результат

3. Уникальность email:

$user->delete();
User::create(['email' => $user->email]);   // ошибка unique

4. Забыли про связи:

$user->delete();
// $user->posts остались неудалёнными

5. Не чистят старые:

// через год: 10 000 000 soft-deleted записей

Итог

  • Soft Deletes — deleted_at вместо физического удаления.
  • withTrashed — включить удалённые.
  • onlyTrashed — только удалённые.
  • restore, forceDelete — восстановить, удалить навсегда.
  • Pruning — автоочистка.
  • Уникальность — составной индекс.
  • Связи — не забывай про них.

Дальше — скоупы.