Implement Auditable Soft Delete in PayloadCMS

javascriptnodejspayloadcmstypescript

September 24, 2025  |  3 min read

TLDR

  • PayloadCMS has a built-in trash feature for soft-deleting
  • Enable it with trash: true in the collection config
  • Add a beforeDelete hook to prevent permanent deletions, ensuring data is only ever soft-deleted
  • Use custom fields like deletedAt and deletedBy for a clear audit trail

Some Context

In any application, data integrity is key. There are times when a user might delete something by accident, or when you need to retain records for auditing purposes even after they are "removed" from the application. Permanent deletion can lead to lost data and broken relationships between documents in the database.

To solve this, we leverage PayloadCMS v3 to implement the soft-delete feature. Instead of destroying records, we simply flag them as "deleted," keeping them safely in the database.

The Implementation

Our approach combines PayloadCMS's native features with a few custom hooks to create a robust soft-delete system. It's a three-step process:

1. Enable the "Trash" Feature

First, we enable the trash functionality in the configuration of each collection where we want to prevent permanent data loss.

// src/collections/{{ YourCollections }}.ts
 
export const YourCollections: CollectionConfig = {
  trash: true, // Enable trash to allow soft delete
  // ... other configurations
};

With this flag, PayloadCMS automatically treats deleted documents as "trashed" instead of removing them.

2. Add Fields for Tracking

To know who deleted what and when, we add a few custom fields to our collections using a helper function.

// src/collections/{{ YourCollections }}.ts
 
// ... (collection config definition)
// Add this field definition
{
  name: 'deletedAt',
  type: 'date',
  // ...
},
// ...

This automatically adds deletedAt and deletedBy fields, giving us a clear and useful audit trail for every soft-deleted document.

3. Prevent Permanent Deletion

Finally, to make the system truly robust, we add a beforeDelete hook. This hook intentionally throws an error if any part of the system attempts to perform a permanent delete, making it impossible to bypass the trash system.

// src/hooks/disallowPermenentDelete.ts
 
export const disallowPermenentDelete: CollectionBeforeDeleteHook = () => {
  throw new APIError('Permanent delete is not allowed.');
};
// src/collections/{{ YourCollections }}.ts
 
// ... (collection config definition)
// Add disallowPermenentDelete to hooks definition
hooks: {
  beforeDelete: [disallowPermenentDelete],
},
// ...

This hook is then added to the collection's configuration, acting as a final safeguard.

4. Bonus: Track Who Deleted What

// src/hooks/maybeToggleDeletedBy.ts
 
export const maybeToggleDeletedBy: FieldHook = async ({ req, operation, data }) => {
  // PayloadCMS uses `update` operation for soft delete, as of v3.53.0, so we check for that
  const isUpdateOperation = operation === 'update';
  const isBeingDeleted = !!data?.deletedAt;
 
  // Cutomize as needed
  if (isUpdateOperation && isBeingDeleted && req.user) {
    return req.user.id;
  }
 
  // If not being deleted, or no user in request, return null to clear the field
  return null;
};
// src/collections/{{ YourCollections }}.ts
 
// ... (collection config definition)
// Add this field definition
{
  name: 'deletedBy',
  type: 'relationship',
  relationTo: 'users', // Cutomize as needed
  hooks: {
    beforeChange: [maybeToggleDeletedBy],
  },
  // ...
},
// ...

Pour Closure

By combining a native feature with a couple of simple, custom hooks, we've built a reliable soft-delete system. It protects against accidental data loss, ensures referential integrity, and provides a clear audit trail without adding significant complexity to the codebase. It's a simple solution that provides a lot of peace of mind.


Refs