By Sensinum
Soft delete plugin for Strapi v5 — never lose content again. Restore entries, auto-purge, lifecycle hooks, RBAC
released October 9, 2026
npm install strapi-soft-delete-pluginSoft delete plugin for Strapi v5 — never lose content again. When you delete an entry, it's marked as deleted instead of being removed from the database. You can restore it or permanently delete it later.
Drop-in replacement for strapi-plugin-soft-delete with significant improvements.
beforeSoftDelete, afterSoftDelete, beforeRestore, afterRestore, beforeDeletePermanently, afterDeletePermanentlybeforeUpdate/afterUpdate hooks| Strapi Version | Plugin Version |
|---|---|
| ^5.0.0 | 0.x |
| ^4.x | Use strapi-plugin-soft-delete |
npm install strapi-soft-delete-plugin
# or
yarn add strapi-soft-delete-pluginAdd to your plugin configuration:
// config/plugins.ts
export default () => ({
'soft-delete': {
enabled: true,
},
});Rebuild and start:
npm run build
npm run developThe plugin declares no runtime dependencies. Everything its admin UI imports (@strapi/admin, @strapi/design-system, @strapi/icons, react, react-dom, react-intl, react-query, react-router-dom, styled-components) is a peerDependency that every Strapi v5 app already provides through @strapi/admin. No extra installs are needed.
This matters for react-query in particular: the plugin's hooks run inside the QueryClientProvider that Strapi's admin mounts, so they must resolve to the same react-query module instance as the host. Declaring it as a peer guarantees that.
Automatically permanently delete soft-deleted entries older than a specified number of days:
// config/plugins.ts
export default () => ({
'soft-delete': {
enabled: true,
config: {
autoPurge: {
enabled: true,
ttlDays: 30, // permanently delete after 30 days
cron: '0 2 * * *', // run daily at 2 AM
},
},
},
});Auto-purge uses the same per-document permanent-delete path as the admin's "Delete permanently": components and dynamic zones are cleaned up, the beforeDeletePermanently/afterDeletePermanently hooks fire, and an entry.delete event is emitted per purged entry. Two purge-specific behaviors:
beforeDeletePermanently.Configure per-role in Settings → Roles → [Role Name]:
| Section | Permission | Description |
|---|---|---|
| Collection & Single Types | Soft Delete | Soft delete entries (replaces "Delete") |
| Collection & Single Types | Deleted Read | View soft-deleted entries in the explorer |
| Collection & Single Types | Deleted Restore | Restore soft-deleted entries |
| Collection & Single Types | Delete Permanently | Permanently remove soft-deleted entries |
| Plugins → Soft Delete | Read | Access the Soft Delete explorer in the sidebar |
| Plugins → Soft Delete | Settings | Manage plugin settings |
Configure in Settings → Soft Delete → Restoration Behavior:
Single Type Restoration — when restoring a single type entry that already has an active entry:
Soft Delete — soft-delete the existing active entry (default)Delete Permanently — permanently delete the existing active entryDraft & Publish Restoration — when restoring an entry from a D&P content type:
Unchanged — preserve the original publish state (default)Draft — always restore as draftThe plugin works out of the box without any custom code. If you need to run custom logic during soft-delete operations (e.g., logging, notifications, validation), you can register lifecycle hooks in your application's bootstrap:
// src/index.ts
export default {
async bootstrap({ strapi }) {
const hooks = strapi.plugin('soft-delete').service('lifecycle-hooks');
hooks.register('beforeSoftDelete', async ({ uid, documentId, entries, auth }) => {
console.log(`About to soft-delete ${documentId} from ${uid}`);
// Return { cancel: true } to prevent the operation,
// or throw to fail it with your own error
});
hooks.register('afterRestore', async ({ uid, documentId, entries, auth }) => {
console.log(`Restored ${documentId} in ${uid}`);
});
},
};Available hooks:
beforeSoftDelete / afterSoftDeletebeforeRestore / afterRestorebeforeDeletePermanently / afterDeletePermanentlySince 0.2.0, handler errors and cancellations propagate to the caller instead of being silently swallowed:
| Handler behavior | Result |
|---|---|
before* handler throws | Operation aborts; the error propagates to the caller (e.g. errors.ApplicationError from @strapi/utils → HTTP 400 in the admin) |
before* handler returns { cancel: true } | Operation aborts with a PolicyError — "Operation cancelled by <hookName> hook" (HTTP 403) |
before* returns { cancel: true, error } | Operation aborts with your error |
after* handler throws | The error propagates to the caller. For soft-delete and restore the operation is ROLLED BACK (the write and the after* hooks share a plugin-owned transaction). For permanent delete the rows are already gone — the error surfaces but nothing is restored |
Notes:
after* hooks run inside one plugin-owned transaction, so host cascades are atomic: if your afterSoftDelete cascade fails, the parent is NOT left half-trashed. Events (entry.delete/entry.update) are emitted only after the transaction commits.errors.ApplicationError (HTTP 400) or errors.PolicyError (HTTP 403) from @strapi/utils. A plain errors.ForbiddenError reaches the caller as a generic "Forbidden" — Strapi's route layer masks its message.The plugin emits entry.delete (actions soft-delete and delete-permanently) and entry.update (action restore) to the event hub, with the plugin identified in the payload: plugin: { id: 'soft-delete', action }.
Event entries are sanitized against the content type's schema before they are emitted — the same defaultSanitizeOutput the core document service applies to its own entry.* events. Password-type attributes and every private: true attribute never reach webhook consumers. The plugin's own bookkeeping fields (_softDeletedAt, _softDeletedById, _softDeletedByType) are private attributes and are stripped too, matching the v4 plugin's behavior — the event's plugin.action field already tells consumers what happened.
Server-side code (bootstrap, cron jobs, other plugins) can drive soft-delete operations through the api service:
const api = strapi.plugin('soft-delete').service('api');
// Same code path as an intercepted documents().delete — hooks + events fire
await api.softDelete('api::article.article', documentId);
// i18n: soft-delete a single locale — other locales stay live
// (mirrors documents().delete({ documentId, locale }); '*' or omitted = all locales)
await api.softDelete('api::article.article', documentId, { locale: 'fr' });
// Fires beforeRestore/afterRestore; respects restoration-behavior settings
await api.restore('api::article.article', documentId);
// Includes component/dynamic-zone cleanup
await api.deletePermanently('api::article.article', documentId);
// Paginated trash listing (only soft-deleted documents)
const trashed = await api.findSoftDeleted('api::article.article', { page: 1, pageSize: 10 });
// Run reads with the soft-delete filter off — the ONLY supported way to see trashed rows
const rows = await api.withSoftDeleted(() =>
strapi.db.query('api::article.article').findMany({ where: { _softDeletedAt: { $ne: null } } }),
);Notes:
ApplicationError when uid is not an api:: content type.softDelete, restore, and deletePermanently run the plugin lifecycle hooks exactly like the admin operations do — the returned promise rejects when a before* handler throws or returns { cancel: true }, and when an after* handler throws (see Hook Error Semantics).{ auth: { id, strategy } } as the last argument to attribute the operation; by default attribution is resolved from the current request.softDelete accepts an optional { locale } for localized content types: a concrete locale soft-deletes only that locale's rows (the intercepted admin delete honors its locale param the same way); '*' or omitted soft-deletes every locale.import type { SoftDeleteApi } from 'strapi-soft-delete-plugin'.Soft-delete a product's variants together with the product, and bring them back on restore:
// src/index.ts
export default {
async bootstrap({ strapi }) {
const hooks = strapi.plugin('soft-delete').service('lifecycle-hooks');
const api = strapi.plugin('soft-delete').service('api');
hooks.register('afterSoftDelete', async ({ uid, documentId }) => {
if (uid !== 'api::product.product') return;
const variants = await strapi.db.query('api::variant.variant').findMany({
where: { product: { documentId } },
});
for (const variant of variants) {
await api.softDelete('api::variant.variant', variant.documentId);
}
});
hooks.register('afterRestore', async ({ uid, documentId }) => {
if (uid !== 'api::product.product') return;
// Trashed variants are invisible to normal reads — look them up with the filter off
const variants = await api.withSoftDeleted(() =>
strapi.db.query('api::variant.variant').findMany({
where: { product: { documentId }, _softDeletedAt: { $ne: null } },
}),
);
for (const variant of variants) {
await api.restore('api::variant.variant', variant.documentId);
}
});
},
};A failure while cascading (e.g. one api.softDelete call rejects) propagates out of the after* hook and rolls the parent operation back — the plugin-owned transaction spans the parent write, your after* cascade, and the children's writes, so the whole cascade is atomic.
_softDeletedAt, _softDeletedById, _softDeletedByType) to all api::* content types at boot timedelete operations into updates that set the soft-delete fields_softDeletedAt IS NULL into every query, hiding soft-deleted entries from normal API responses and populated relationsSee MIGRATION.md for a step-by-step guide.
# Install dependencies
npm install
# Build the plugin
npm run build
# Type check (server + admin + tests)
npm run type-check
# Lint
npm run lint
# Format
npm run format
# Unit tests
npm run test
# Unit tests with coverage
npm run test:coverage
# E2E tests (requires running Strapi instance)
npm run test:e2eShare your work with the community and get it listed in the Strapi ecosystem for everyone to discover and use.
Submit
