Using Soft Deletes in AdonisJS Lucid ORM: Enable, Query, and Restore
Learn how to add soft deletes to an AdonisJS 5 model, delete and restore rows safely, and verify the behavior with standard Lucid queries.
26 May 2026, 04:04 UTC

Problem: accidental hard deletes
In a typical AdonisJS application a call to Model.delete() permanently removes the row from the database. If the delete was unintentional or you later need to audit the removed data, the information is gone and recovery requires a backup or point‑in‑time restore.
Thesis: soft deletes give a safety net
By adding the SoftDeletes trait and a nullable deleted_at column, the delete() method only marks the row. Queries automatically ignore rows where deleted_at IS NOT NULL unless you explicitly ask for trashed records, giving you a reversible delete with virtually no code change.
Enabling soft deletes
Add the migration
Create a migration that adds a timestamp column named deleted_at to the table you want to protect. Run the command from the project root (you need write access to the database/migrations folder).
adonis make:migration add_deleted_at_to_posts --table=posts
Edit the generated file:
class AddDeletedAtToPosts extends Migration {
public function up () {
this.table('posts', (table) => {
table.timestamp('deleted_at', { useTz: true }).nullable()
})
}
public function down () {
this.table('posts', (table) => {
table.dropColumn('deleted_at')
})
}
}
Update the model
Import the trait and add it to the model class.
const Model = use('Model')
const SoftDeletes = use('@adonisjs/lucid/src/SoftDeletes')
class Post extends Model {
static get traits () {
return [
SoftDeletes
]
}
}
module.exports = Post
Run the migration:
adonis migration:run
Using soft deletes
Deleting and restoring
From a route, controller, or Ace Tinker you can delete a record as usual.
// ace tinker
const Post = use('App/Models/Post')
const post = await Post.find(1)
await post.delete()
The row remains in the posts table with a timestamp in deleted_at. To restore:
await post.restore()
If you really need to remove the row, use forceDelete().
Querying with trashed rows
Regular queries ignore soft‑deleted rows.
const active = await Post.query().fetch() // only where deleted_at IS NULL
const withTrashed = await Post.withTrashed().fetch() // includes soft‑deleted
const onlyTrashed = await Post.onlyTrashed().fetch() // only where deleted_at IS NOT NULL
Verification steps
- Run adonis migration:run to add the column.
- Create a test record via Tinker: await Post.create({ title: 'test' }).
- Delete it: await post.delete().
- Inspect the database (e.g., sqlite3 dev.sqlite3 'SELECT * FROM posts WHERE id = 1;') – you should see the row with a non‑null deleted_at value.
- Restore: await post.restore() and verify deleted_at is null.
- Run the three queries above and confirm the counts match expectations.
Trade‑off and limitation
- Index on deleted_at: Adding an index improves the performance of the automatic scope but adds write overhead; consider a partial index if your DB supports it.
- Global scopes: If you define a custom global scope that adds its own where clause, make sure it does not inadvertently conflict with the built‑in soft‑delete scope. You can combine them by chaining scopes or disabling the soft‑delete scope with withoutGlobalScopes() when needed.
Actionable closing
Add the SoftDeletes trait to a base model (e.g., BaseModel) so all inheriting models get the behavior automatically. After each migration, run the verification steps to ensure the column exists and the scoping works. This gives you a reversible delete strategy with minimal code change and clear audit trail.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.