Describe the Bug
Collection and global hooks (beforeValidate, beforeChange, afterChange, beforeRead, afterRead, beforeDelete, afterDelete) do not receive operation-level intent arguments like draft, autosave, or trash. These arguments represent caller intent — what the operation is trying to do — and are distinct from document state (e.g., _status: 'draft').
This is inconsistent: beforeOperation and afterOperation hooks already receive the full args object containing all operation arguments. Field-level afterRead hooks also receive draft. But the most commonly used hooks — beforeChange, afterChange, beforeRead, afterRead — do not.
Why this is a bug, not a feature request:
-
Inconsistency in the hook API. beforeOperation/afterOperation get full args, all other hooks get an incomplete hand-picked subset. There's no documented reason for the difference.
-
The context workaround is unsafe. The suggested workaround is to stash values in context via beforeOperation, then read them in other hooks. But context is a shared mutable object — if a hook triggers a nested operation (e.g., payload.update() inside afterChange), that nested operation's beforeOperation hook overwrites the same context keys, corrupting the values for the outer operation. This is a race condition by design.
-
Intent vs. state divergence causes incorrect behavior. For example, a beforeChange hook cannot distinguish between:
- An update that publishes a draft (
draft: false, but _status is still 'draft' on the incoming doc)
- An update that saves a new draft (
draft: true)
- An autosave (
autosave: true) where you'd want to skip side effects like sending notifications
- A soft-delete/trash operation (
trash: true) where the update is just setting deletedAt
Current hook arg availability:
| Hook |
draft |
autosave |
trash |
Full args |
beforeOperation |
via args |
via args |
via args |
yes |
afterOperation |
via args |
via args |
via args |
yes |
beforeValidate |
no |
no |
no |
no |
beforeChange |
no |
no |
no |
no |
afterChange |
no |
no |
no |
no |
beforeRead |
no |
n/a |
no |
no |
afterRead |
no |
n/a |
no |
no |
beforeDelete |
n/a |
n/a |
no |
no |
afterDelete |
n/a |
n/a |
no |
no |
Field afterRead |
yes |
no |
no |
no |
Race condition example with context workaround:
const myCollection: CollectionConfig = {
hooks: {
beforeOperation: [
({ args, context }) => {
context.draft = args.draft // stash for later hooks
},
],
afterChange: [
async ({ doc, req, context }) => {
// context.draft is correct here... unless:
await req.payload.update({
collection: 'other-collection',
id: doc.relatedId,
data: { synced: true },
// This triggers OTHER collection's beforeOperation,
// which may overwrite context.draft
})
// context.draft may now reflect the INNER operation's value, not ours
},
],
},
}
Proposed fix: Add args as a property to all hook types — the same full operation args object that beforeOperation/afterOperation already receive. This is backwards compatible (new optional property) and future-proof (new operation args are automatically available without threading them individually).
hooks: {
beforeChange: [({ data, operation, req, args }) => {
if (args.draft) { /* draft save — skip external sync */ }
if (args.autosave) { /* autosave — skip notifications */ }
}],
}
Link to the code that reproduces this issue
https://github.com/payloadcms/payload/blob/main/packages/payload/src/collections/config/types.ts
Reproduction Steps
- Create a collection with
versions: { drafts: true } and a beforeChange hook
- In the hook, try to access the
draft argument: ({ draft }) => { console.log(draft) }
- Call
payload.create({ collection: 'my-collection', data: { title: 'test' }, draft: true })
- Observe that
draft is undefined in the hook — the argument is not forwarded
- Same issue with
autosave in write hooks and trash in delete/update hooks
Which area(s) are affected?
area: core
Environment Info
Binaries:
Node: 24.3.0
npm: 11.4.2
Yarn: 1.22.22
pnpm: 10.29.3
Relevant Packages:
payload: 3.79.0
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.6.0: Mon Jan 19 22:01:58 PST 2026; root:xnu-11417.140.69.708.3~1/RELEASE_ARM64_T6041
Available memory (MB): 24576
Available CPU cores: 14
Describe the Bug
Collection and global hooks (
beforeValidate,beforeChange,afterChange,beforeRead,afterRead,beforeDelete,afterDelete) do not receive operation-level intent arguments likedraft,autosave, ortrash. These arguments represent caller intent — what the operation is trying to do — and are distinct from document state (e.g.,_status: 'draft').This is inconsistent:
beforeOperationandafterOperationhooks already receive the fullargsobject containing all operation arguments. Field-levelafterReadhooks also receivedraft. But the most commonly used hooks —beforeChange,afterChange,beforeRead,afterRead— do not.Why this is a bug, not a feature request:
Inconsistency in the hook API.
beforeOperation/afterOperationget full args, all other hooks get an incomplete hand-picked subset. There's no documented reason for the difference.The
contextworkaround is unsafe. The suggested workaround is to stash values incontextviabeforeOperation, then read them in other hooks. Butcontextis a shared mutable object — if a hook triggers a nested operation (e.g.,payload.update()insideafterChange), that nested operation'sbeforeOperationhook overwrites the samecontextkeys, corrupting the values for the outer operation. This is a race condition by design.Intent vs. state divergence causes incorrect behavior. For example, a
beforeChangehook cannot distinguish between:draft: false, but_statusis still'draft'on the incoming doc)draft: true)autosave: true) where you'd want to skip side effects like sending notificationstrash: true) where the update is just settingdeletedAtCurrent hook arg availability:
draftautosavetrashargsbeforeOperationargsargsargsafterOperationargsargsargsbeforeValidatebeforeChangeafterChangebeforeReadafterReadbeforeDeleteafterDeleteafterReadRace condition example with
contextworkaround:Proposed fix: Add
argsas a property to all hook types — the same full operation args object thatbeforeOperation/afterOperationalready receive. This is backwards compatible (new optional property) and future-proof (new operation args are automatically available without threading them individually).Link to the code that reproduces this issue
https://github.com/payloadcms/payload/blob/main/packages/payload/src/collections/config/types.ts
Reproduction Steps
versions: { drafts: true }and abeforeChangehookdraftargument:({ draft }) => { console.log(draft) }payload.create({ collection: 'my-collection', data: { title: 'test' }, draft: true })draftisundefinedin the hook — the argument is not forwardedautosavein write hooks andtrashin delete/update hooksWhich area(s) are affected?
area: core
Environment Info