Skip to content

(feat) ORM: soft deletes via #[SoftDeletes] - #354

Merged
techmahedy merged 1 commit into
doppar:4.xfrom
abdulmajidcse:feat/orm-soft-deletes
Oct 9, 2026
Merged

techmahedy merged 1 commit into
doppar:4.xfrom
abdulmajidcse:feat/orm-soft-deletes

Conversation

@abdulmajidcse

Copy link
Copy Markdown
Member

Pull Request Checklist

Q A
Branch? 4.x
Bug fix? yes (two small fixes, see below)
New feature? yes
Deprecations? no
Issues -
License MIT

Description

The migration builder already offers softDeletes() / dropSoftDeletes(), but the ORM never used the column: delete() always removed the row and nothing filtered stamped rows. This PR adds soft deletes to the ORM, configured with an attribute like #[Temporal] and #[Hook].

#[SoftDeletes]                        // or #[SoftDeletes(column: 'removed_at')]
class Post extends Model {}

$post->delete();                      // UPDATE ... SET deleted_at = now (and updated_at)
$post->trashed();                     // true
$post->restore();                     // deleted_at = NULL
$post->forceDelete();                 // real DELETE

Post::all();                          // excludes trashed rows
Post::withTrashed()->get();
Post::onlyTrashed()->get();
Post::where('author_id', 2)->delete();    // bulk soft delete
Post::onlyTrashed()->forceDelete();       // bulk permanent delete
Post::where('id', 1)->restore();          // bulk restore (targets trashed rows)

How it works

  • One constraint, applied at compile time. Entity\Builder overrides buildWhereClause() (the trait original is aliased) and appends table.deleted_at IS NULL / IS NOT NULL. Everything that compiles a WHERE clause picks it up: get, first, find, count, exists, aggregates, paginate, cursorPaginate, chunkById, bulk update / increment / delete. The constraint is never stored in $conditions, so code that copies conditions between builders (such as many-to-many eager loading) cannot duplicate it.
  • OR cannot bypass it. The user's conditions are wrapped in parentheses: WHERE (a = ? OR b = ?) AND posts.deleted_at IS NULL. Nested where(fn ...) groups don't repeat the constraint.
  • The column is qualified the way the FROM clause names the table, including an alias (from('posts as p') → p.deleted_at), so it resolves on every driver.
  • Key-based writes skip the constraint. save(), update(), increment(), decrement() and delete() on a model instance call withoutSoftDeleteScope(). Otherwise saving a model loaded with withTrashed() would update 0 rows and still report success.
  • Relations. Lazy and eager relations and embedCount() use query() and are covered automatically. The hand-built EXISTS subqueries behind present(), absent(), ifExists(), whereLinked() and their nested forms (articles.notes, articles.labels) get an explicit constraint for every soft-deletable table in the chain. The many-to-many subquery joins the related table when it needs that column. A withTrashed() or onlyTrashed() call inside the callback is honoured.
  • Hooks. A soft delete fires before_deleted / after_deleted. forceDelete() fires them too, and isForceDeleting() tells the two apart. New before_restored / after_restored hooks.
  • #[Temporal] interplay. Restores are recorded as a restored snapshot (it fits the existing VARCHAR(10) action column). restoreTo() carries the snapshot's soft delete state, because save() only writes $creatable columns.
  • Route-model binding goes through find() / where()->first(), so trashed models resolve to a 404 with no extra code.
  • Calling withTrashed(), restore(), forceDelete() and similar on a model without the attribute throws a clear LogicException. Models without the attribute behave exactly as before.

Fixes included

  • Builder::fresh() declared ?self but returns a model, so it threw a TypeError on every call. Its return type is now ?Model, and it keeps the builder's trashed mode.
  • purge() soft-deletes on soft-deletable models and doesn't restamp rows that are already trashed.

Notes for review

  • Existing Temporal behavior, not touched here: Model::at($t)->find($id) returns the last non-deleted snapshot even when the record was deleted at $t, while at($t)->get() correctly excludes it. Happy to fix it in a separate PR.
  • fork() copies deleted_at, like Laravel's replicate(). fork(['deleted_at']) avoids it.
  • firstOrCreate() and updateOrCreate() respect the default constraint, so they don't see trashed rows. This is consistent with Laravel.

Tests

  • tests/Model/SoftDelete/SoftDeleteBehavior.php: 46 tests that run on SQLite, MySQL and PostgreSQL through the existing driver test-case pattern, plus SoftDeleteTemporalTest (4 tests) for the #[Temporal] interplay.
  • Verified locally against SQLite, MySQL 5.7 and PostgreSQL 16: full suite 4266 tests passing, with the same 4 skips as 4.x. PHPStan is clean.
  • Each safeguard (OR wrapping, nested groups, key-based writes, every subquery path, purge, fresh, alias, model sync, restoreTo) was checked by temporarily breaking it and confirming a test fails.

Checklist

  • Tests have been added or updated
  • Documentation has been updated if necessary (docs PR to follow once the API is approved)
  • Code follows the project coding standards
  • All tests pass locally

Mark a model with #[SoftDeletes] (optionally #[SoftDeletes(column: 'removed_at')])
and delete() stamps the column instead of removing the row. Every query on the
model excludes trashed rows unless withTrashed() or onlyTrashed() is used.

- Model: delete(), forceDelete(), restore(), trashed(), isForceDeleting()
- Builder: withTrashed(), onlyTrashed(), withoutTrashed(), restore(), forceDelete();
  bulk delete() and purge() soft-delete on soft-deletable models
- The constraint is added when the WHERE clause is compiled, after wrapping the
  user's conditions in parentheses so an OR cannot bypass it; nested where groups
  do not repeat it
- Key-based writes on a model (save, update, increment, decrement, delete) ignore
  the constraint so a trashed model can still be saved
- present/absent/ifExists/whereLinked subqueries, including nested and
  many-to-many chains, exclude trashed related rows and honour withTrashed()
  or onlyTrashed() inside the callback
- New before_restored / after_restored hooks; #[Temporal] records a 'restored'
  snapshot, and restoreTo() carries the soft delete state of the snapshot
- Fix Builder::fresh() return type (it returns a Model, not a Builder)
@techmahedy

Copy link
Copy Markdown
Member

@abdulmajidcse big change, need to check it carefully, great features bro. many thanks

@techmahedy techmahedy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for this, @abdulmajidcse. It's a thorough piece of work, and we're going to merge it.

I checked it end to end on my side.

@techmahedy
techmahedy merged commit eefe929 into doppar:4.x Oct 9, 2026
16 checks passed
@techmahedy

Copy link
Copy Markdown
Member

@abdulmajidcse, please update the docs

@abdulmajidcse

Copy link
Copy Markdown
Member Author

@abdulmajidcse, please update the docs

I'll update docs very soon.

@techmahedy

techmahedy commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

@

@abdulmajidcse, please update the docs

I'll update docs very soon.

@abdulmajidcse thanks, make sure follow the doppar convension of current docs

@abdulmajidcse

Copy link
Copy Markdown
Member Author

@techmahedy, the docs PR is open.

@techmahedy

Copy link
Copy Markdown
Member

@techmahedy, the docs PR is open.

thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants