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:
Aspect
Archive (custom status flag)
Trash (Payload's built-in trash: true)
Question it answers
"Should this be live on the site right now?"
"Did someone try to delete this, and can we undo it?"
Who sets it
An editor, deliberately, as a content decision
Payload core, automatically, whenever a delete is attempted
Where it's implemented
Plain fields you define and own (archived, archivedAt)
A deletedAt field Payload injects and manages for you
UI surface
Whatever you build — usually a filter on the existing list view
A dedicated /collections/:slug/trash route with its own list, Restore, and Permanently Delete actions
Triggered by
Editing the document like any other field
Clicking Delete (with a "skip trash" checkbox for a true hard delete)
Document state while active
Fully live, fully editable — just filtered out of default views
Read-only; only Restore and Permanently Delete are available
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/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.tsimporttype { 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.
*/exportfunctioncancelRelatedRecordsOnTrash(): CollectionAfterChangeHook {
returnasync ({ doc, previousDoc, req }) => {
const wasTrashed = Boolean(
previousDoc && typeof previousDoc === "object" && "deletedAt"in previousDoc
&& (previousDoc asRecord<string, unknown>).deletedAt,
);
const isTrashed = Boolean(
doc && typeof doc === "object" && "deletedAt"in doc
&& (doc asRecord<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:
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
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.
GET /api/posts?trash=true&where[deletedAt][exists]=true
The pattern to remember: trash: true alone means "include trashed documents in the result," not "only trashed documents." You always need the deletedAtexists 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.tsimporttype { Access } from"payload";
exportconstpostsDeleteAccess: Access = ({ req: { user }, data }) => {
if (!user) returnfalse;
// Admins can do either — trash or permanent delete.if (user.roles?.includes("admin")) returntrue;
// 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) returntrue;
returnfalse;
};
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:
Situation
Recommendation
Editorial content multiple people can delete, where a mistake is costly to redo (articles, pages, campaigns)
Enable trash. This is the exact case it exists for — an undo path for an action that used to be irreversible.
Collections already governed by a strict workflow/state machine where hard delete is rare and admin-only anyway
Optional. You already have a safety net in the form of process; trash adds a second one, which isn't harmful, just possibly redundant.
You want an "is this live" editorial flag, separate from delete recovery
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.
High-volume, low-stakes, or system-generated records (logs, webhook events, generated caches)
Skip it. Undo is not a meaningful concept for records nobody hand-authored, and the extra `deletedAt` column/index is pure overhead.
Collections with existing afterDelete hooks doing real cleanup work
Enable it, but budget time for the hook-transition fix described above — this is the case most likely to break silently.
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.