tuzelko/yii2-softdelete

Soft delete extension for Yii2 framework

Maintainers

Package info

github.com/TuzelKO/yii2-softdelete

Type:yii2-extension

pkg:composer/tuzelko/yii2-softdelete

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 2

Open Issues: 0

v1.0.0 2026-04-03 03:48 UTC

This package is auto-updated.

Last update: 2026-04-03 06:32:43 UTC


README

Latest Stable Version Total Downloads License

Soft-delete extension for the Yii2 framework.

Adds soft-delete (and restore) behaviour to any ActiveRecord model via a single PHP trait, with an accompanying query class that automatically hides deleted records from all queries.

Features

  • Three column strategies — Unix timestamp (int), DB-native datetime, or boolean flag
  • Automatic query scope — deleted records are invisible by default; opt in with withDeleted() / onlyDeleted()
  • Instance methodsdelete(), restore(), forceDelete(), isSoftDeleted()
  • Bulk methodsdeleteAll(), restoreAll(), forceDeleteAll(), updateAll() (all scope-aware)
  • EventsbeforeSoftDelete, afterSoftDelete, beforeRestore, afterRestore
  • Multi-database — MySQL, PostgreSQL, SQLite, SQL Server, Oracle
  • Zero configuration — sensible defaults, override only what you need

Requirements

  • PHP >= 8.0
  • yiisoft/yii2 ~2.0

Installation

composer require tuzelko/yii2-softdelete

Quick start

1. Add the column to your migration

// Unix timestamp (default)
$this->addColumn('{{%post}}', 'deleted_at', $this->integer()->null()->defaultValue(null));

// — or — boolean flag
$this->addColumn('{{%article}}', 'is_deleted', $this->boolean()->notNull()->defaultValue(false));

2. Apply the trait to your model

use tuzelko\yii\softdelete\SoftDeleteTrait;
use yii\db\ActiveRecord;

class Post extends ActiveRecord
{
    use SoftDeleteTrait;

    public static function tableName(): string
    {
        return 'post';
    }
}

That's it. Post::find() now returns only non-deleted records, and $post->delete() soft-deletes instead of hard-deletes.

Column strategies

Override softDeleteColumn() and softDeleteType() when the defaults do not fit your schema.

Constant Column value when deleted Column value when restored
TYPE_TIMESTAMP_INT (default) time() (Unix timestamp) NULL
TYPE_TIMESTAMP_DB NOW() / datetime('now') / etc. NULL
TYPE_BOOL 1 0
class Article extends ActiveRecord
{
    use SoftDeleteTrait;

    public static function tableName(): string { return 'article'; }

    public static function softDeleteColumn(): string { return 'is_deleted'; }
    public static function softDeleteType(): int      { return self::TYPE_BOOL; }
}

Instance methods

$post = Post::findOne(1);

$post->delete();          // soft-delete — sets deleted_at, hides from default scope
$post->isSoftDeleted();   // true

$post->restore();         // clears deleted_at, record becomes visible again
$post->isSoftDeleted();   // false

$post->forceDelete();     // permanent hard-delete (fires standard Yii2 before/afterDelete events)

Query scopes

// Default — excludes soft-deleted records (no extra call needed)
Post::find()->all();

// Include soft-deleted records alongside active ones
Post::find()->withDeleted()->all();

// Only soft-deleted records
Post::find()->onlyDeleted()->all();

Bulk operations

// Soft-delete all active records matching the condition
Post::deleteAll(['status' => 'spam']);

// Restore all soft-deleted records
Post::restoreAll();

// Restore specific records
Post::restoreAll(['id' => [3, 5, 7]]);

// Permanently delete all soft-deleted records
Post::forceDeleteAll(['is not', 'deleted_at', null]);

// updateAll() also skips soft-deleted records automatically
Post::updateAll(['status' => 'archived'], ['category_id' => 2]);

Auto-scope behaviour: deleteAll(), updateAll(), and restoreAll() automatically add a "not deleted" (or "deleted") condition unless your $condition already references the soft-delete column. This prevents double-applying the scope when you target records explicitly.

Relations

Because SoftDeleteTrait overrides find() to return a SoftDeleteActiveQuery, the soft-delete scope is automatically applied to every relation that points to a soft-delete-enabled model — including eager loading via with() and join-based loading via joinWith().

Declaring relations

class User extends ActiveRecord
{
    // hasMany — only active (non-deleted) posts are returned
    public function getPosts(): SoftDeleteActiveQuery
    {
        return $this->hasMany(Post::class, ['user_id' => 'id']);
    }

    // To include deleted records in a relation, call withDeleted() on it
    public function getAllPosts(): SoftDeleteActiveQuery
    {
        return $this->hasMany(Post::class, ['user_id' => 'id'])->withDeleted();
    }

    // hasOne — same rules apply
    public function getLatestPost(): SoftDeleteActiveQuery
    {
        return $this->hasOne(Post::class, ['user_id' => 'id'])
            ->orderBy(['created_at' => SORT_DESC]);
    }
}
class Post extends ActiveRecord
{
    use SoftDeleteTrait;

    public static function tableName(): string { return 'post'; }

    // Relation to a model that does NOT use soft-delete — works as usual
    public function getUser(): \yii\db\ActiveQuery
    {
        return $this->hasOne(User::class, ['id' => 'user_id']);
    }

    // Relation to another soft-delete model
    public function getComments(): SoftDeleteActiveQuery
    {
        return $this->hasMany(Comment::class, ['post_id' => 'id']);
    }
}

Eager loading with with()

// Load users and only their active posts (soft-delete scope applied automatically)
$users = User::find()->with('posts')->all();

foreach ($users as $user) {
    foreach ($user->posts as $post) {
        // $post is never soft-deleted
    }
}

// Load users together with ALL their posts (including deleted)
$users = User::find()
    ->with(['posts' => fn($q) => $q->withDeleted()])
    ->all();

// Load users with only their deleted posts
$users = User::find()
    ->with(['posts' => fn($q) => $q->onlyDeleted()])
    ->all();

JOIN-based loading with joinWith()

Both models' soft-delete scopes are applied automatically. The condition for each model is placed in the JOIN's ON clause (not WHERE), which preserves correct LEFT JOIN semantics: a user with no active posts still appears with NULL post columns rather than disappearing.

// LEFT JOIN (default) — all users appear; only active posts are joined
// Generated SQL: ... LEFT JOIN post ON user.id = post.user_id AND post.deleted_at IS NULL
//                    WHERE user.deleted_at IS NULL
$users = User::find()->joinWith('posts')->all();

// INNER JOIN — only users with at least one active post are returned
$users = User::find()->joinWith('posts', false, 'INNER JOIN')->all();

Column names are always table-qualified (post.deleted_at), so joining two tables that both have a soft-delete column never produces an "ambiguous column" SQL error.

The relation callback works the same as with with():

// Include deleted posts in the JOIN
$users = User::find()->joinWith(['posts' => fn($q) => $q->withDeleted()])->all();

// JOIN only with deleted posts (e.g. to find users with pending cleanup)
$users = User::find()
    ->joinWith(['posts' => fn($q) => $q->onlyDeleted()], false, 'INNER JOIN')
    ->all();

Events

All four events receive a yii\base\ModelEvent. Setting $event->isValid = false in a before* handler cancels the operation.

Constant When
SoftDeleteTrait::EVENT_BEFORE_SOFT_DELETE Before delete() writes to the DB
SoftDeleteTrait::EVENT_AFTER_SOFT_DELETE After delete() succeeds
SoftDeleteTrait::EVENT_BEFORE_RESTORE Before restore() writes to the DB
SoftDeleteTrait::EVENT_AFTER_RESTORE After restore() succeeds
$post->on(Post::EVENT_BEFORE_SOFT_DELETE, function (\yii\base\ModelEvent $event) {
    if (!Yii::$app->user->can('deletePost')) {
        $event->isValid = false; // cancel the soft-delete
    }
});

forceDelete() fires the standard Yii2 ActiveRecord::EVENT_BEFORE_DELETE / EVENT_AFTER_DELETE events.

Performance

Add an index on the soft-delete column so that the automatic scope does not cause a full-table scan:

// In your migration
$this->createIndex('idx_post_deleted_at', 'post', 'deleted_at');

// Boolean column — a partial index (supported by PostgreSQL and SQLite) is even more efficient
$this->createIndex('idx_article_is_deleted', 'article', 'is_deleted');

Without the index every find(), deleteAll(), updateAll(), and restoreAll() call will scan the whole table once it grows large.

Cascade soft-deletes

Soft-deleting a parent record does not automatically soft-delete its children. If you need cascading behaviour, implement it in the afterSoftDelete hook:

class Post extends ActiveRecord
{
    use SoftDeleteTrait;

    public static function tableName(): string { return 'post'; }

    public function afterSoftDelete(): void
    {
        Comment::deleteAll(['post_id' => $this->id]);
    }

    public function afterRestore(): void
    {
        Comment::restoreAll(['post_id' => $this->id]);
    }
}

String conditions and auto-scope

deleteAll(), updateAll(), and restoreAll() automatically add a soft-delete scope unless your $condition already references the soft-delete column. This detection works for array conditions only. Plain SQL strings are not inspected.

If you pass a raw string that already targets the soft-delete column, wrap it in an array to prevent the scope from being added twice:

// ✗ scope is added twice — the string is not inspected
Post::deleteAll("deleted_at IS NULL AND category_id = 5");

// ✓ wrap in an array so the column is detected
Post::deleteAll(['and', ['is', 'deleted_at', null], ['category_id' => 5]]);

Running tests

make test

Tests run inside Docker (PHP 8.3 + SQLite) with no local setup required.

License

MIT — see LICENSE.