Skip to content

Soft Deletes (Paranoid Mode)

Soft deletes allow you to "delete" records without physically removing them from the database. Instead, a deleted_at timestamp is set. This is useful for audit trails, undo functionality, and compliance requirements.


Enabling Soft Deletes

@Schema(
  tableName: 'users',
  paranoid: true,   // ← Enable soft deletes
)
class User extends Model with _$PhormUserMixin { ... }

The generator adds the deleted_at TEXT column to the SQL schema and injects a DateTime? deletedAt field into your generated mixin automatically. You can access it on your model instance without any manual declaration.


How It Works

Phorm Architecture

Operation paranoid: false paranoid: true
delete(id) DELETE FROM ... WHERE id = ? UPDATE ... SET deleted_at = NOW()
delete(id, force: true) DELETE FROM ... DELETE FROM ... (bypasses soft delete)
readOne(id) Returns record Returns null if deleted_at IS NOT NULL
readAll() All records Only records where deleted_at IS NULL
readAll(withDeleted: true) All records All records including deleted
readAll(onlyDeleted: true) All records Only records where deleted_at IS NOT NULL

Reading Soft-Deleted Records

// Only active records (default)
final result = await userService.readAll();

// Include deleted records
final result = await userService.readAll(withDeleted: true);

// Only deleted records (e.g., for a "Trash" screen)
final result = await userService.readAll(onlyDeleted: true);

// Read a specific record regardless of deletion status
final user = await userService.readOne('id', withDeleted: true);

// Check if ID exists including deleted
final exists = await userService.exists('id', withDeleted: true);

Restoring Records

// Un-delete a record (clears deleted_at, updates updated_at)
await userService.restore('user_id');

// Bulk restore
await userService.restoreBatch(['id1', 'id2', 'id3']);

Warning

Calling restore on a table with paranoid: false throws StateError: Soft delete not enabled. Always check your table configuration.


Hard Delete

Even with paranoid: true, you can permanently delete a record:

// Permanent delete, bypasses soft delete
await userService.delete('user_id', force: true);

// Bulk hard delete
await userService.deleteBatch(['id1', 'id2'], force: true);

Custom deleted_at Field

If you want to customize the deletedAt field (e.g., to use a different column name or add specific annotations), you can declare it manually in your model class:

// Declare manually in your class for customization
@Column(columnName: 'removed_at')
final DateTime? deletedAt;

When declared manually, the generator will use your field instead of creating a default one in the mixin.


Pitfalls

1. Filtering on deleted_at manually

If you add a manual condition on deleted_at in your WhereBuilder, PHORM will not add the automatic IS NULL filter (it detects if deleted_at already has a condition):

// This works correctly — PHORM skips auto-filter because you set it manually
final result = await userService.readAll(
  where: WhereBuilder().isNotNull('deleted_at'),
);

2. upsert with soft-deleted records

upsert uses INSERT OR REPLACE. If the ID already exists (even with deleted_at set), the entire row is replaced — including deleted_at, which will be set by _withTimestamps as null. This effectively restores a soft-deleted record silently.

When using include (eager loading), related tables that are themselves paranoid are filtered automatically: the JSON subquery adds related.deleted_at IS NULL, so soft-deleted children do not appear in the loaded relationship. Related tables without paranoid: true are returned as-is.

To exclude soft-deleted related records, you would need a custom query approach.