Skip to content

feat: adds trash support (soft deletes) #12656

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 65 commits into from
Jul 25, 2025
Merged
Show file tree
Hide file tree
Changes from 52 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
e5757bb
feat: exposes new deletedAt field on collection config
PatrikKozak Jun 2, 2025
aa62733
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 2, 2025
c3be174
feat: adds trash arg to collection find operation
PatrikKozak Jun 2, 2025
b693706
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 2, 2025
5113e38
feat: adds soft-delete test suite
PatrikKozak Jun 3, 2025
e8bf074
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
6a84864
feat: adds trash flag to find, update & delete local operations
PatrikKozak Jun 3, 2025
97a3890
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
667f326
feat: adds trash flag to find, update & delete endpoints
PatrikKozak Jun 3, 2025
52100bc
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
283f0f3
feat: adds softDeletes root level prop to collection configs to enabl…
PatrikKozak Jun 3, 2025
3499feb
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
48cf719
feat: only push query on trash arg if softDeletes is enabled on the c…
PatrikKozak Jun 3, 2025
8d38ac7
feat: adjust tests for sql adapters
PatrikKozak Jun 3, 2025
9fafda9
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 3, 2025
2d42035
feat: adds trash flag to find, update & delete graphql operations
PatrikKozak Jun 4, 2025
e638bb0
chore: re-adds commented out graphql tests for update & delete many o…
PatrikKozak Jun 4, 2025
58055c7
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 4, 2025
6b5e2fb
feat: adds trash arg to findVersions & findVersionByID operations
PatrikKozak Jun 4, 2025
f179c4c
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 4, 2025
bbd8dbb
feat: conditionally use string or int graphql literals for ids depend…
PatrikKozak Jun 5, 2025
a430aae
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 5, 2025
40eaaff
feat: adds delete access control in update operations to prevent soft…
PatrikKozak Jun 5, 2025
4a61a1f
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 5, 2025
18293ac
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 6, 2025
fcbaa5c
feat: adds deletedAt translations for bengali languages
PatrikKozak Jun 6, 2025
f2ef8cb
feat: overrideAccess check out of isSoftDeleteAttempt
PatrikKozak Jun 9, 2025
321c028
feat: adds tests for docs that were soft deleted and then moved out o…
PatrikKozak Jun 9, 2025
2f0db73
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 9, 2025
599b7d5
feat: renames new trash operations arg to softDeletes
PatrikKozak Jun 9, 2025
813b7b6
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 9, 2025
adae3f8
feat: updates collection config option & operations arg to trash over…
PatrikKozak Jun 11, 2025
509e354
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 11, 2025
5b8d425
feat: updates test collection config names
PatrikKozak Jun 11, 2025
b0b9963
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 12, 2025
bce2cab
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 13, 2025
ffa47d6
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 16, 2025
7ffb1cf
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 22, 2025
5f846e8
chore: update test suite collection names
PatrikKozak Jun 22, 2025
a84ad53
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 24, 2025
5ae60ed
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 24, 2025
eafefc0
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 27, 2025
fd3955c
feat: extract reusable trash filter logic into appendNonTrashedFilter…
PatrikKozak Jun 27, 2025
33077b3
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 27, 2025
854293d
fix: handle null string in date filters to prevent Invalid Date error
PatrikKozak Jun 27, 2025
6d52869
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 27, 2025
63f41d8
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jun 30, 2025
64a8a50
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 3, 2025
2a34c03
chore: renames test suite from soft-deletes to trash
PatrikKozak Jul 3, 2025
0cd6aa3
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 3, 2025
8f2ff2f
feat: admin UI for trash-enabled collections (#12735)
PatrikKozak Jul 8, 2025
af7a539
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 8, 2025
d9a06b0
Merge main
PatrikKozak Jul 21, 2025
ce04d53
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 23, 2025
a1be0ab
feat: ui updates to trash supported collections (#13235)
PatrikKozak Jul 24, 2025
792e8db
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 24, 2025
e85c894
feat: updates restore modals based on if collection has version draft…
PatrikKozak Jul 24, 2025
0537fb5
feat: respect trash arg in login operation
PatrikKozak Jul 24, 2025
c6d2624
feat: adds trash arg to forgotPassword
PatrikKozak Jul 24, 2025
33ec1ee
feat: adds trash arg to registerFirstUser
PatrikKozak Jul 24, 2025
31b7667
Merge branch 'main' of github.com:payloadcms/payload into feat/soft-d…
PatrikKozak Jul 24, 2025
d918981
feat: filter out trashed docs for all auth operations with payload.db…
PatrikKozak Jul 24, 2025
2759019
fix: re-adds merged where query for viewing trashed docs in list view
PatrikKozak Jul 24, 2025
a9cbfd3
Merge branch 'feat/soft-delete' of github.com:payloadcms/payload into…
PatrikKozak Jul 24, 2025
1565753
feat: allow viewing trashed users & disable auth fields in user doc i…
PatrikKozak Jul 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ jobs:
- plugin-nested-docs
- plugin-seo
- sort
- trash
- versions
- uploads
env:
Expand Down Expand Up @@ -457,6 +458,7 @@ jobs:
- plugin-nested-docs
- plugin-seo
- sort
- trash
- versions
- uploads
env:
Expand Down
7 changes: 7 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts trash",
"cwd": "${workspaceFolder}",
"name": "Run Dev Trash",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts uploads",
"cwd": "${workspaceFolder}",
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/collections.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ The following options are available:
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. |
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| `trash` | A boolean to enable soft deletes for this collection. Defaults to `false`. [More details](../trash/overview). |
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
Expand Down
1 change: 1 addition & 0 deletions docs/live-preview/server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export default async function Page() {
collection: 'pages',
id: '123',
draft: true,
trash: true, // add this if trash is enabled in your collection and want to preview trashed documents
})

return (
Expand Down
199 changes: 199 additions & 0 deletions docs/trash/overview.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
---
title: Trash
label: Overview
order: 10
desc: Enable soft deletes for your collections to mark documents as deleted without permanently removing them.
keywords: trash, soft delete, deletedAt, recovery, restore
---

Trash (also known as soft delete) allows documents to be marked as deleted without being permanently removed. When enabled on a collection, deleted documents will receive a `deletedAt` timestamp, making it possible to restore them later, view them in a dedicated Trash view, or permanently delete them.

Soft delete is a safer way to manage content lifecycle, giving editors a chance to review and recover documents that may have been deleted by mistake.

<Banner type="warning">
**Note:** The Trash feature is currently in beta and may be subject to change
in minor version updates.
</Banner>

## Collection Configuration

To enable soft delete for a collection, set the `trash` property to `true`:

```ts
import type { CollectionConfig } from 'payload'

export const Posts: CollectionConfig = {
slug: 'posts',
trash: true,
fields: [
{
name: 'title',
type: 'text',
},
// other fields...
],
}
```

When enabled, Payload automatically injects a deletedAt field into the collection's schema. This timestamp is set when a document is soft-deleted, and cleared when the document is restored.

## Admin Panel behavior

Once `trash` is enabled, the Admin Panel provides a dedicated Trash view for each collection:

- A new route is added at `/collections/:collectionSlug/trash`
- The Trash view shows all documents that have a `deletedAt` timestamp

From the Trash view, you can:

- Use bulk actions to manage trashed documents:

- **Restore** to clear the `deletedAt` timestamp and return documents to their original state
- **Delete** to permanently remove selected documents
- **Empty Trash** to select and permanently delete all trashed documents at once

- Enter each document's **edit view**, just like in the main list view. While in the edit view of a trashed document:
- All fields are in a **read-only** state
- All document actions (e.g. Save, Publish, Restore Version) are hidden or disabled
- Preserve access to the **API**, **Versions**, and **Preview** views

When deleting a document from the main collection List View, Payload will soft-delete the document by default. A checkbox in the delete confirmation modal allows users to skip the trash and permanently delete instead.

## API Support

Soft deletes are fully supported across all Payload APIs: **Local**, **REST**, and **GraphQL**.

The following operations respect and support the `trash` functionality:

- `find`
- `findByID`
- `update`
- `updateByID`
- `delete`
- `deleteByID`
- `findVersions`
- `findVersionByID`

### Understanding `trash` Behavior

Passing `trash: true` to these operations will **include soft-deleted documents** in the query results.

To return _only_ soft-deleted documents, you must combine `trash: true` with a `where` clause that checks if `deletedAt` exists.

### Examples

#### Local API

Return all documents including trashed:

```ts
const result = await payload.find({
collection: 'posts',
trash: true,
})
```

Return only trashed documents:

```ts
const result = await payload.find({
collection: 'posts',
trash: true,
where: {
deletedAt: {
exists: true,
},
},
})
```

Return only non-trashed documents:

```ts
const result = await payload.find({
collection: 'posts',
trash: false,
})
```

#### REST

Return **all** documents including trashed:

```http
GET /api/posts?trash=true
```

Return **only trashed** documents:

```http
GET /api/posts?trash=true&where[deletedAt][exists]=true
```

Return only non-trashed documents:

```http
GET /api/posts?trash=false
```

#### GraphQL

Return all documents including trashed:

```ts
query {
Posts(trash: true) {
docs {
id
deletedAt
}
}
}
```

Return only trashed documents:

```ts
query {
Posts(
trash: true
where: { deletedAt: { exists: true } }
) {
docs {
id
deletedAt
}
}
}
```

Return only non-trashed documents:

```ts
query {
Posts(trash: false) {
docs {
id
deletedAt
}
}
}
```

## Access Control

All trash-related actions (delete, permanent delete) respect the `delete` access control defined in your collection config.

This means:

- If a user is denied delete access, they cannot soft delete or permanently delete documents

## Versions and Trash

When a document is soft-deleted:

- It can no longer have a version **restored** until it is first restored from trash
- Attempting to restore a version while the document is in trash will result in an error
- This ensures consistency between the current document state and its version history

However, versions are still fully **visible and accessible** from the **edit view** of a trashed document. You can view the full version history, but must restore the document itself before restoring any individual version.
11 changes: 8 additions & 3 deletions packages/drizzle/src/queries/sanitizeQueryValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,14 @@ export const sanitizeQueryValue = ({

if (field.type === 'date' && operator !== 'exists') {
if (typeof val === 'string') {
formattedValue = new Date(val).toISOString()
if (Number.isNaN(Date.parse(formattedValue))) {
return { operator, value: undefined }
if (val === 'null' || val === '') {
formattedValue = null
} else {
const date = new Date(val)
if (Number.isNaN(date.getTime())) {
return { operator, value: undefined }
}
formattedValue = date.toISOString()
}
} else if (typeof val === 'number') {
formattedValue = new Date(val).toISOString()
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -49,6 +50,7 @@ export function getDeleteResolver<TSlug extends CollectionSlug>(
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await deleteByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/find.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where?: Where
},
context: {
Expand Down Expand Up @@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver {
pagination: args.pagination,
req,
sort: args.sort,
trash: args.trash,
where: args.where,
}

Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type Resolver<TData> = (
fallbackLocale?: string
id: string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -50,6 +51,7 @@ export function findByIDResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await findByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findVersionByID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Resolver<T extends TypeWithID = any> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand All @@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver {
collection,
depth: 0,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await findVersionByIDOperation(options)
Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/findVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type Resolver = (
page?: number
pagination?: boolean
sort?: string
trash?: boolean
where: Where
},
context: {
Expand Down Expand Up @@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver {
pagination: args.pagination,
req: isolateObjectProperty(req, 'transactionID'),
sort: args.sort,
trash: args.trash,
where: args.where,
}

Expand Down
2 changes: 2 additions & 0 deletions packages/graphql/src/resolvers/collections/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
fallbackLocale?: string
id: number | string
locale?: string
trash?: boolean
},
context: {
req: PayloadRequest
Expand Down Expand Up @@ -54,6 +55,7 @@ export function updateResolver<TSlug extends CollectionSlug>(
depth: 0,
draft: args.draft,
req: isolateObjectProperty(req, 'transactionID'),
trash: args.trash,
}

const result = await updateByIDOperation<TSlug>(options)
Expand Down
Loading
Loading