tuzelko / yii2-softdelete
Soft delete extension for Yii2 framework
Package info
github.com/TuzelKO/yii2-softdelete
Type:yii2-extension
pkg:composer/tuzelko/yii2-softdelete
Requires
- php: >=8.0
- yiisoft/yii2: ~2.0
Requires (Dev)
- phpunit/phpunit: ^9.0
README
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 methods —
delete(),restore(),forceDelete(),isSoftDeleted() - Bulk methods —
deleteAll(),restoreAll(),forceDeleteAll(),updateAll()(all scope-aware) - Events —
beforeSoftDelete,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(), andrestoreAll()automatically add a "not deleted" (or "deleted") condition unless your$conditionalready 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.