Implementing Soft Deletes in AdonisJS Lucid ORM
Learn how to implement soft deletes in AdonisJS Lucid ORM to prevent permanent data loss, manage trashed records, and handle unique constraint conflicts.
14 Dec 2025, 14:48 UTC

Preventing Permanent Data Loss with Soft Deletes
In production environments, accidental data deletion is a common risk. Instead of permanently removing a row from the database—which makes recovery impossible without a backup restore—Lucid ORM provides a soft delete mechanism. This approach marks a record as deleted by updating a timestamp, effectively hiding it from standard queries while preserving the audit trail.
Enabling Soft Deletes
To implement soft deletes, you must synchronize your database schema with your model configuration. If you enable the feature in the model but omit the database column, Lucid will attempt to update a non-existent field, resulting in a database error.
1. Database Migration
Add a nullable deleted_at timestamp to your table. Run the migration command in your terminal:
# Run as a user with permissions to modify the database schema
node ace make:migration add_deleted_at_to_posts
In the generated migration file, define the column:
import { Schema, BaseSchema } from '@adonisjs/lucid/schema'export default class extends BaseSchema { protected async up() { this.schema.alterTable('posts', (table) => { table.timestamp('deleted_at', { useTz: true }).nullable() }) } protected async down() { this.schema.alterTable('posts', (table) => { table.dropColumn('deleted_at') }) } }
2. Model Configuration
Set the softDeletes property to true within your Lucid model. This tells the ORM to intercept the delete() method.
import { BaseModel, column } from '@adonisjs/lucid/orm'export default class Post extends BaseModel { public static softDeletes = true @column({ isPrimary: true }) public id: number @column() public title: string @column.dateTime() public deletedAt: Date | null }
Managing Soft-Deleted Records
Once enabled, the standard delete() method no longer removes the row. It sets the deleted_at column to the current time.
| Goal | Method/Query | Result |
|---|---|---|
| Soft Delete | post.delete() |
Sets deleted_at; record hidden from find(). |
| Include Deleted | Post.withTrashed().find(id) |
Returns record regardless of deletion status. |
| Query Only Deleted | Post.onlyTrashed().select('*') |
Returns only records where deleted_at is not null. |
| Restore Record | post.restore() |
Sets deleted_at back to null. |
| Permanent Delete | post.forceDelete() |
Executes a SQL DELETE statement. |
Critical Limitations and Common Pitfalls
Unique Constraint Collisions
Database-level UNIQUE constraints do not recognize soft deletes. If you have a unique constraint on a slug column and soft-delete a post with the slug "hello-world", you cannot create a new post with that same slug. The database still sees the row. To solve this, you may need to include deleted_at in a composite unique index or handle the collision in your application logic.
Lack of Cascading Deletes
Soft deletes are model-scoped. If a User has many Posts and you soft-delete the user, the associated posts remain active in the database. Lucid does not automatically propagate soft deletes to relationships. You must manually iterate through related models and call delete() on them if you wish to hide the entire tree.
Foreign Key Constraints
When using forceDelete(), standard SQL foreign key constraints still apply. If another table has a restricted foreign key pointing to the record you are trying to permanently remove, the operation will fail. Ensure you handle child records before calling forceDelete().
Verification Checklist
To verify the implementation is working as expected, perform these checks in a development environment:
- Verify Hidden State: Call
await post.delete(), then attemptawait Post.find(post.id). It should returnnull. - Verify Persistence: Call
await Post.withTrashed().find(post.id). It should return the record with a populateddeletedAttimestamp. - Verify Restoration: Call
await post.restore(), then verifyawait Post.find(post.id)returns the record again.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.