Repository navigation
(feat) ORM: soft deletes via #[SoftDeletes] - #354
Merged
Merged
Conversation
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)
Member
|
@abdulmajidcse big change, need to check it carefully, great features bro. many thanks |
techmahedy
approved these changes
Oct 9, 2026
techmahedy
left a comment
Member
There was a problem hiding this comment.
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.
Member
|
@abdulmajidcse, please update the docs |
Member
Author
I'll update docs very soon. |
Member
|
@
@abdulmajidcse thanks, make sure follow the doppar convension of current docs |
Member
Author
|
@techmahedy, the docs PR is open. |
Member
thanks |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request Checklist
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].How it works
Entity\BuilderoverridesbuildWhereClause()(the trait original is aliased) and appendstable.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, bulkupdate/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.ORcannot bypass it. The user's conditions are wrapped in parentheses:WHERE (a = ? OR b = ?) AND posts.deleted_at IS NULL. Nestedwhere(fn ...)groups don't repeat the constraint.from('posts as p')→p.deleted_at), so it resolves on every driver.save(),update(),increment(),decrement()anddelete()on a model instance callwithoutSoftDeleteScope(). Otherwise saving a model loaded withwithTrashed()would update 0 rows and still report success.embedCount()usequery()and are covered automatically. The hand-built EXISTS subqueries behindpresent(),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. AwithTrashed()oronlyTrashed()call inside the callback is honoured.before_deleted/after_deleted.forceDelete()fires them too, andisForceDeleting()tells the two apart. Newbefore_restored/after_restoredhooks.#[Temporal]interplay. Restores are recorded as arestoredsnapshot (it fits the existingVARCHAR(10)action column).restoreTo()carries the snapshot's soft delete state, becausesave()only writes$creatablecolumns.find()/where()->first(), so trashed models resolve to a 404 with no extra code.withTrashed(),restore(),forceDelete()and similar on a model without the attribute throws a clearLogicException. Models without the attribute behave exactly as before.Fixes included
Builder::fresh()declared?selfbut returns a model, so it threw aTypeErroron 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
Model::at($t)->find($id)returns the last non-deleted snapshot even when the record was deleted at$t, whileat($t)->get()correctly excludes it. Happy to fix it in a separate PR.fork()copiesdeleted_at, like Laravel'sreplicate().fork(['deleted_at'])avoids it.firstOrCreate()andupdateOrCreate()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, plusSoftDeleteTemporalTest(4 tests) for the#[Temporal]interplay.4.x. PHPStan is clean.restoreTo) was checked by temporarily breaking it and confirming a test fails.Checklist