---
title: "Payload CMS Trash: Preserve Hooks on Soft Delete Guide"
slug: "payload-cms-trash-preserve-hooks-soft-delete"
published: "2026-09-05"
updated: "2026-09-14"
validated: "2026-09-13"
categories:
  - "Payload"
tags:
  - "Payload CMS trash"
  - "soft delete Payload"
  - "afterDelete hook"
  - "afterChange hook"
  - "deletedAt updateByID"
  - "Payload 3.x"
  - "database migration Postgres"
  - "trash vs archive"
  - "restore version constraint"
  - "access control delete"
  - "cancel related records on trash"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload@3.88.0"
  - "node@20"
  - "typescript@5"
  - "postgres@15"
status: "stable"
llm-purpose: "Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Node.js"
  - "Access to TypeScript"
  - "Access to Postgres"
llm-outputs:
  - "Completed outcome: Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…"
---

**Summary Triples**
- (Payload trash feature, implements, soft-delete by updating deletedAt via updateByID (not hard delete))
- (afterDelete hooks, stop running, when a collection has trash: true because the operation becomes an update)
- (To preserve cleanup logic, use, an afterChange hook that detects deletedAt transition (null → timestamp))
- (Detection method, is, compare previousDoc.deletedAt (null) to doc.deletedAt (non-null) inside afterChange)
- (Restores, detect, deletedAt transition (timestamp → null) in afterChange to undo soft-delete cleanup)
- (Permanent/hard deletes, still trigger, afterDelete hooks (when a record is actually removed, not soft-deleted))
- (Archive vs Trash, differs, archive is usually a custom boolean/field-based approach; trash is built-in soft-delete with admin Restore/Permanently Delete actions)
- (Migration risk, requires, audit of existing afterDelete logic and migration to afterChange to avoid silent behavior changes)

### {GOAL}
Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…

### {PREREQS}
- Access to Payload CMS
- Access to Node.js
- Access to TypeScript
- Access to Postgres

### {STEPS}
1. Assess collections for trash
2. Add trash:true and run migration
3. Audit existing afterDelete hooks
4. Implement afterChange transition hook
5. Wire hook into collection hooks
6. Test trash, restore, and versions
7. Restrict permanent delete via access control

<!-- llm:goal="Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Postgres" -->
<!-- llm:output="Completed outcome: Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…" -->

# Payload CMS Trash: Preserve Hooks on Soft Delete Guide
> Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…
Matija Žiberna · 2026-09-05

Payload CMS 3.x ships a built-in trash feature: add `trash: true` to a collection config, and Payload gives you a `deletedAt` field, a dedicated Trash admin view, and Restore / Permanently Delete actions — no custom fields, no custom UI. I rolled it out across roughly twenty collections in a production Payload project this week, and the one-line version of the feature is genuinely that simple. What is not simple, and what nothing in the docs calls out directly, is that Payload implements the "soft delete" as an **update**, not a delete — which means any `afterDelete` hook logic you already have (cancelling related records, purging derived data, invalidating something on removal) silently stops running the moment you flip this flag on. This guide walks through the full config surface, the archive-vs-trash distinction that trips people up, and the fix for the hook gap, using the real collection config and hook code from that rollout.

## The situation: a CMS with more collections than delete discipline

The project is a multi-site Payload 3.88.0 instance with about twenty content collections — articles, events, promotional campaigns, webinars, static pages, plus the taxonomy and comment collections that hang off them (tags, categories, event tickets, event registrations, blog comments). Editors across several sites work in the same admin panel, and until this week, deleting a document from any list view was permanent. Click delete, confirm, it is gone. No undo, no trash can, nothing.

That is a fine tradeoff for some collections. It is a bad one for editorial content that took real work to produce, especially once you have several people with delete access across several sites. The ask was simple: give every content collection a delete safety net, the same way most CMS-adjacent tools (WordPress, Notion, Google Drive) already do it.

The interesting part was not turning the feature on — that part took an afternoon. It was figuring out how it interacts with the workflow machinery that already existed on those collections: draft/publish versioning, an approval pipeline that gates publishing behind reviewer sign-off, and hooks that purge derived data when a document is removed. That interaction is the part worth writing up, because it will bite anyone adding trash to a collection that already has `afterDelete` hooks doing real work.

## Trash and "archive" are not the same feature, even though they look like it

Before touching any config, it is worth being precise about what trash actually is, because a lot of Payload projects — this one included — already have a custom "archive" or "status" field doing something that looks adjacent. In this project, nine of the twenty collections already had `archived` and `archivedAt` fields: a checkbox an editor sets to retire a piece of content from the live site, hidden from the default admin list via a `baseFilter`, with its own scheduling and its own "is this thing currently live" logic tied into the publish-approval workflow.

The two features solve different problems, and understanding the difference will save you from either duplicating work or wiring them together unnecessarily:

<table>
<thead>
<tr><th>Aspect</th><th>Archive (custom status flag)</th><th>Trash (Payload's built-in <code>trash: true</code>)</th></tr>
</thead>
<tbody>
<tr><td>Question it answers</td><td>"Should this be live on the site right now?"</td><td>"Did someone try to delete this, and can we undo it?"</td></tr>
<tr><td>Who sets it</td><td>An editor, deliberately, as a content decision</td><td>Payload core, automatically, whenever a delete is attempted</td></tr>
<tr><td>Where it's implemented</td><td>Plain fields you define and own (<code>archived</code>, <code>archivedAt</code>)</td><td>A <code>deletedAt</code> field Payload injects and manages for you</td></tr>
<tr><td>UI surface</td><td>Whatever you build — usually a filter on the existing list view</td><td>A dedicated <code>/collections/:slug/trash</code> route with its own list, Restore, and Permanently Delete actions</td></tr>
<tr><td>Triggered by</td><td>Editing the document like any other field</td><td>Clicking Delete (with a "skip trash" checkbox for a true hard delete)</td></tr>
<tr><td>Document state while active</td><td>Fully live, fully editable — just filtered out of default views</td><td>Read-only; only Restore and Permanently Delete are available</td></tr>
</tbody>
</table>

Because neither feature reads or writes the other's fields, they compose without conflict. A document can be `archived: true` and, independently, later get soft-deleted into trash — the two states don't interact at the data level at all. If you already have an archive-style status field, you do not need to rip it out or reconcile it before adding trash; you're answering two different questions with two different mechanisms.

## The gotcha: trash is implemented as an update, not a delete

This is the part that is easy to miss, and it matters if any of your collections have `beforeDelete` or `afterDelete` hooks doing work you rely on — cascading cleanup, cancelling related workflow records, purging data derived from the document.

I confirmed this by reading Payload's own `updateByID` operation source rather than relying on the docs. When you click "Delete" on a document in a trash-enabled collection (and leave the "permanently delete" checkbox unchecked), Payload does not call its `delete` operation at all. It calls `update`, setting `data.deletedAt` to the current timestamp:

```
// node_modules/payload/dist/collections/operations/utilities/update.js
const isRestoringDraftFromTrash = Boolean(originalDoc?.deletedAt) && data?._status !== 'published';
```

```
// node_modules/payload/dist/collections/operations/updateByID.js
const isTrashAttempt = collectionConfig.trash && typeof data === 'object' && data !== null
  && 'deletedAt' in data && data.deletedAt != null;
```

The practical consequence: `beforeChange` and `afterChange` hooks fire normally, because this really is just another update to the document. But `beforeDelete` and `afterDelete` do not fire — those only run on a genuine `delete` operation, which now only happens when someone permanently deletes a document from the Trash view (or passes `trash: false` explicitly through the API to bypass soft delete). If your collection has an `afterDelete` hook that, say, cancels a related approval request or purges data extracted from the document body, that hook will quietly stop running for the vast majority of "deletes," because most deletes are now soft deletes.

I found this the hard way in the collection I was migrating: it had an `afterDelete` hook that cancelled any in-flight approval request tied to the document, and another that purged content derived from the document body. Both would have gone stale — approval requests stuck "active" forever, derived data staying live — for any document a user trashed instead of hard-deleted, which after this change is the default path.

The fix is to detect the untrashed-to-trashed transition yourself, inside `afterChange`, and run the equivalent cleanup there:

```typescript
// File: src/payload/hooks/cancel-on-trash.ts
import type { CollectionAfterChangeHook } from "payload";

/**
 * Payload implements trash's soft-delete as an updateByID call setting
 * deletedAt, not a delete operation — so afterDelete hooks never fire when
 * a document is trashed, only on a later permanent delete. This mirrors
 * whatever cleanup your afterDelete hook does, keyed off the
 * untrashed -> trashed transition instead of the delete operation.
 */
export function cancelRelatedRecordsOnTrash(): CollectionAfterChangeHook {
  return async ({ doc, previousDoc, req }) => {
    const wasTrashed = Boolean(
      previousDoc && typeof previousDoc === "object" && "deletedAt" in previousDoc
        && (previousDoc as Record<string, unknown>).deletedAt,
    );
    const isTrashed = Boolean(
      doc && typeof doc === "object" && "deletedAt" in doc
        && (doc as Record<string, unknown>).deletedAt,
    );

    // Only act on the moment it flips from live to trashed — not on every
    // subsequent edit while it stays trashed, and not on restore.
    if (wasTrashed || !isTrashed) return doc;

    // Run whatever your afterDelete hook already does here — cancel related
    // records, purge derived data, etc. On restore (deletedAt cleared),
    // the next afterChange call naturally re-runs your normal afterChange
    // logic with real content again, so nothing needs to be un-done manually.

    return doc;
  };
}
```

Wire it into `afterChange` alongside your existing hooks:

```typescript
// File: src/payload/collections/Posts/index.ts
import type { CollectionConfig } from "payload";
import { cancelRelatedRecordsOnTrash } from "@/payload/hooks/cancel-on-trash";

export const Posts: CollectionConfig = {
  slug: "posts",
  trash: true,
  hooks: {
    afterChange: [cancelRelatedRecordsOnTrash()],
    afterDelete: [/* existing hard-delete cleanup stays here, unchanged */],
  },
  fields: [/* ... */],
};
```

One thing you likely do **not** need to fix: cache invalidation tied to `_status` (published/draft). Trashing a document doesn't touch `_status` at all — it only sets `deletedAt` — and a normal `afterChange` cache-invalidation hook that checks whether the document is or was published will still fire correctly on the trash-triggering update, since that update goes through the regular `afterChange` chain. It's specifically `afterDelete`-only side effects that go stale, not anything already wired into `afterChange`.

## Full reference: what `trash: true` actually gives you

### The one-line config

```typescript
// File: src/payload/collections/Posts/index.ts
import type { CollectionConfig } from "payload";

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

That single boolean does four things automatically, and you don't configure any of them separately:

- Injects a `deletedAt` date field into the collection schema (and, if the collection has `versions.drafts` enabled, a matching `deletedAt` field on the versions table too — Payload's migration output names it `version_deleted_at`).
- Adds a `/collections/posts/trash` admin route with its own list view, filtered to documents where `deletedAt` is set.
- Changes the delete confirmation modal in the admin UI to include a "permanently delete" checkbox. Left unchecked (the default), delete becomes soft delete. Checked, it's a real hard delete, same as before this feature existed.
- Makes a trashed document's edit view read-only, with only Restore and Permanently Delete available as actions — Save, Publish, and Restore Version are hidden.

If you're on Postgres, this is a real schema change — `trash: true` adds a `deleted_at` column (and index) per collection, which means you need to generate and run a migration before it works in any environment beyond local dev with auto-push.

### API behavior across Local, REST, and GraphQL

All three API surfaces respect `trash` the same way, and the default behavior is what you'd expect: without passing `trash: true` explicitly, `find`, `findByID`, `update`, and `delete` all silently exclude trashed documents, as if they didn't exist.

<table>
<thead>
<tr><th>What you want</th><th>Local API</th><th>REST</th></tr>
</thead>
<tbody>
<tr><td>Default: only live documents</td><td><code>payload.find({ collection: "posts" })</code></td><td><code>GET /api/posts</code></td></tr>
<tr><td>All documents, live and trashed</td><td><code>payload.find({ collection: "posts", trash: true })</code></td><td><code>GET /api/posts?trash=true</code></td></tr>
<tr><td>Only trashed documents</td><td><code>payload.find({ collection: "posts", trash: true, where: { deletedAt: { exists: true } } })</code></td><td><code>GET /api/posts?trash=true&where[deletedAt][exists]=true</code></td></tr>
</tbody>
</table>

The pattern to remember: `trash: true` alone means "include trashed documents in the result," not "only trashed documents." You always need the `deletedAt` `exists` filter on top of it to scope down to the trash can specifically. GraphQL follows the same shape, just as a `trash` argument on the query (`Posts(trash: true) { docs { id deletedAt } }`) instead of a query-string param.

### Letting editors trash but restricting permanent delete to admins

This is the part that makes trash actually useful as a governance tool rather than just a UI nicety. Payload's `delete` access control function receives the operation's `data` argument, and it tells you which kind of delete is being attempted: when a user is trying to soft-delete (trash) a document, `data.deletedAt` is set; when they're trying to permanently delete it, `data` is `undefined` entirely. That's your hook for differentiating the two:

```typescript
// File: src/payload/collections/Posts/access.ts
import type { Access } from "payload";

export const postsDeleteAccess: Access = ({ req: { user }, data }) => {
  if (!user) return false;

  // Admins can do either — trash or permanent delete.
  if (user.roles?.includes("admin")) return true;

  // data.deletedAt set means this is a trash attempt, not a permanent one.
  // Regular editors get to trash content but not permanently remove it.
  if (data?.deletedAt) return true;

  return false;
};
```

When this is in place and a non-admin user opens the delete confirmation modal, Payload's admin UI hides the "permanently delete" checkbox entirely for them — they only ever get the soft-delete path, and permanent removal stays an admin-only action from the Trash view.

### The one drafts/versions interaction worth knowing

If the collection has `versions.drafts` enabled, there's a specific ordering constraint: once a document is trashed, you cannot restore an individual version onto it directly. Payload requires the document itself to be restored from trash first, and only then will version restore work again. Attempting to restore a version while the parent document sits in trash throws an error. In practice this rarely comes up, but it explains a confusing error message if you hit it: the fix is "restore the document from trash," not "something is wrong with the version."

## When to actually turn this on

Not every collection needs a trash can, and bolting it onto everything by default just adds a migration and a UI surface nobody uses. Here's the decision framework I used across the twenty collections in this project:

<table>
<thead>
<tr><th>Situation</th><th>Recommendation</th></tr>
</thead>
<tbody>
<tr><td>Editorial content multiple people can delete, where a mistake is costly to redo (articles, pages, campaigns)</td><td>Enable trash. This is the exact case it exists for — an undo path for an action that used to be irreversible.</td></tr>
<tr><td>Collections already governed by a strict workflow/state machine where hard delete is rare and admin-only anyway</td><td>Optional. You already have a safety net in the form of process; trash adds a second one, which isn't harmful, just possibly redundant.</td></tr>
<tr><td>You want an "is this live" editorial flag, separate from delete recovery</td><td>Don't reach for trash — that's what a custom status/archive field is for. Build or keep that instead, and layer trash on top only if accidental deletion is a separate real risk.</td></tr>
<tr><td>High-volume, low-stakes, or system-generated records (logs, webhook events, generated caches)</td><td>Skip it. Undo is not a meaningful concept for records nobody hand-authored, and the extra `deletedAt` column/index is pure overhead.</td></tr>
<tr><td>Collections with existing <code>afterDelete</code> hooks doing real cleanup work</td><td>Enable it, but budget time for the hook-transition fix described above — this is the case most likely to break silently.</td></tr>
</tbody>
</table>

In the project this guide is drawn from, that framework landed on: yes for every editorial content collection and its directly related taxonomy/comment collections, and a deliberate no for internal configuration collections like navigation menus and site chrome, where hard delete is already rare and admin-gated by the existing access rules.

## Frequently asked questions

**Does enabling `trash: true` require a database migration?**
Yes, if you're on a SQL adapter like Postgres. It adds a `deleted_at` column (and an index on it) to the collection's table, plus a `version_deleted_at` column if the collection has draft versions enabled. Generate and run the migration before relying on trash in any shared environment.

**Will old documents automatically get a `deletedAt` value when I enable trash?**
No. Existing documents get `deletedAt` as `null`/unset, which Payload treats as "not trashed" — nothing changes for existing content until someone actually deletes something after the flag is live.

**Can I use trash and a custom archive/status field on the same collection?**
Yes, and you likely should if you already have one. They track different things — archive is an editorial decision about visibility, trash is a delete safety net — and neither reads or writes the other's fields, so there's no data-level conflict to resolve.

**Does trash change what `find()` returns by default?**
Yes. Once a collection has `trash: true`, every `find`/`findByID` call excludes trashed documents unless you explicitly pass `trash: true`. If you have custom queries or public-facing pages fetching from a trash-enabled collection, you don't need to add any exclusion logic yourself — Payload already filters trashed documents out by default.

**What happens to hooks that ran on delete before I added trash?**
`beforeDelete`/`afterDelete` hooks keep working exactly as before for hard deletes (the "permanently delete" path). What changes is that most "deletes" a user performs are now soft deletes by default, and those go through `update`, not `delete` — so those hooks simply won't fire for the common case anymore unless you add the transition-detection pattern shown above.

## Wrapping up

Payload's trash feature is a genuinely well-designed one-line addition for the common case, and the parts that need real thought aren't in the feature itself — they're in how it interacts with whatever else is already wired to your collections' delete behavior. If you're adding it to a collection with no custom delete-side hooks, `trash: true` and a migration is the entire job. If you have `afterDelete` logic doing real work, budget time to mirror it into an `afterChange` transition check, because that gap won't show up in testing unless you specifically go looking for it — everything else about the document will look and behave correctly right up until you notice the stale data it left behind.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Trash: Preserve Hooks on Soft Delete Guide\" cover?",
      "answer": "Payload CMS trash uses a soft-delete (deletedAt) update; learn how to detect the untrashed→trashed transition and mirror afterDelete cleanup in…"
    }
  ]
}
```